@shiyuphan/follow-me 0.7.1 → 0.9.0
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/README.md +9 -1
- package/dist/src/cli.js +181 -42
- package/dist/src/comments.js +215 -9
- package/dist/src/coverage.js +163 -25
- package/dist/src/host.js +23 -0
- package/dist/src/plan.js +128 -6
- package/dist/src/prompt.js +55 -2
- package/dist/src/refs.js +239 -0
- package/dist/src/server.js +65 -3
- package/dist/src/session.js +1 -0
- package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-cHEJvcBn.js → abnfDiagram-WEYOIA3C-DztIEwNE.js} +1 -1
- package/dist/ui/dist/assets/{arc-xolFyc0v.js → arc-DUYh-EqZ.js} +1 -1
- package/dist/ui/dist/assets/{architecture-WOLXFQ4H-B3hvvNV2.js → architecture-WOLXFQ4H-BQYTB92_.js} +1 -1
- package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-BNTMbPJW.js → architectureDiagram-ZHVYKT53-Dl7095A5.js} +1 -1
- package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-NYY6qeuY.js → blockDiagram-RJAE7QA3-QDZ5kE1J.js} +1 -1
- package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-B_bNpXHQ.js → c4Diagram-XDPPPE54-CKJZ1xOw.js} +1 -1
- package/dist/ui/dist/assets/channel-DF08Hd8f.js +1 -0
- package/dist/ui/dist/assets/{chunk-2E7WHVMD-BTet9STn.js → chunk-2E7WHVMD-BXBjaqd1.js} +1 -1
- package/dist/ui/dist/assets/{chunk-4EMNKR2T-qoQt4Fpx.js → chunk-4EMNKR2T-DPzfVuke.js} +1 -1
- package/dist/ui/dist/assets/{chunk-55XCU76K-C3I6EB-H.js → chunk-55XCU76K-B33Sj3w8.js} +1 -1
- package/dist/ui/dist/assets/{chunk-7S6XSTFO-CL-9s_8p.js → chunk-7S6XSTFO-B_u7dS03.js} +1 -1
- package/dist/ui/dist/assets/{chunk-AQDTSKZB-DHoGnGyP.js → chunk-AQDTSKZB-B4EzH5gx.js} +1 -1
- package/dist/ui/dist/assets/{chunk-DSNLSHJZ-Bk8WO5JF.js → chunk-DSNLSHJZ-CfWeHABr.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HAGFOTED-CwAC85lg.js → chunk-HAGFOTED-B8G8PjLZ.js} +1 -1
- package/dist/ui/dist/assets/{chunk-HNE4Q36P-CIllywWr.js → chunk-HNE4Q36P-BA-PqoTF.js} +1 -1
- package/dist/ui/dist/assets/{chunk-NQOKRVIF-DDlpyYiH.js → chunk-NQOKRVIF-JvhuFcnR.js} +1 -1
- package/dist/ui/dist/assets/{chunk-UHW7PFBP-Cxdndcon.js → chunk-UHW7PFBP-BrfL5CGY.js} +1 -1
- package/dist/ui/dist/assets/{chunk-VQ5W62QI-CL9d9_iJ.js → chunk-VQ5W62QI-dBJqgdDy.js} +1 -1
- package/dist/ui/dist/assets/{chunk-WQQL4CDK-C4kzN8QU.js → chunk-WQQL4CDK-dfqd3k_P.js} +1 -1
- package/dist/ui/dist/assets/{chunk-XXDRQBXY-Cv_2FSiY.js → chunk-XXDRQBXY-kViWdB9M.js} +1 -1
- package/dist/ui/dist/assets/{chunk-YZO67HFG-oHiuYaCJ.js → chunk-YZO67HFG-ukfs4wT3.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZAUX2LTX-Cy_8d0XT.js → chunk-ZAUX2LTX-BxZHki81.js} +1 -1
- package/dist/ui/dist/assets/{chunk-ZCHMQSOR-piIpIcQi.js → chunk-ZCHMQSOR-PjluhR_E.js} +1 -1
- package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-DNKRIujO.js → classDiagram-v2-4MYWR3UA-Ct89LJec.js} +1 -1
- package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-BWKTGCHY.js → cose-bilkent-JH36ORCC-BnQceGmB.js} +1 -1
- package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-yx7KKkAk.js → cynefin-EF2NZ3EQ-D6WkCVE8.js} +1 -1
- package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-BTnZ7QVL.js → cynefinDiagram-WF24UDRP-dn6DUPAb.js} +1 -1
- package/dist/ui/dist/assets/{dagre-NK4GIZVX-CPh5Zn5I.js → dagre-NK4GIZVX-BtZSlLk7.js} +1 -1
- package/dist/ui/dist/assets/{diagram-CBK3MWTC-pI6NFdrD.js → diagram-CBK3MWTC-Ds4_tv6X.js} +1 -1
- package/dist/ui/dist/assets/{diagram-I5XIIV2D-DUIfARoS.js → diagram-I5XIIV2D-DcoVkg45.js} +1 -1
- package/dist/ui/dist/assets/{diagram-ODIL2UCW-lafSPvAw.js → diagram-ODIL2UCW-T47FAA3k.js} +1 -1
- package/dist/ui/dist/assets/{diagram-PFBEJUXE-u1lZas3a.js → diagram-PFBEJUXE-CRZcaVKP.js} +1 -1
- package/dist/ui/dist/assets/{diagram-RJA3I4VV-mc0GLMHo.js → diagram-RJA3I4VV-D6KXeUPg.js} +1 -1
- package/dist/ui/dist/assets/{diagram-TYUNFSNR-1NRbBrZW.js → diagram-TYUNFSNR-BHEIVyNN.js} +1 -1
- package/dist/ui/dist/assets/{dist-CHZ9mrl_.js → dist-DLlKIWBp.js} +1 -1
- package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DdStjTDc.js → ebnfDiagram-V4P3TTPT-nUDvYRBV.js} +1 -1
- package/dist/ui/dist/assets/{elk-QONTBCTQ-BzRoFO45.js → elk-QONTBCTQ-D21BcRzc.js} +1 -1
- package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-DlMkgQxj.js → erDiagram-KYQIGQAA-CTpHuftU.js} +1 -1
- package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-BRYIOrod.js → eventmodeling-K75KTNOO-B6JaKuDY.js} +1 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BTeOUR-p.js +1 -0
- package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-D8fQZ7Ah.js → ganttDiagram-R7GPNTJY-D2-pMcnC.js} +1 -1
- package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-C7ZydyWX.js → gitGraph-VSP46ZUC-B2yxwhEo.js} +1 -1
- package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-DCItam0v.js → gitGraphDiagram-PDYL7XHY-DnrhBMH1.js} +1 -1
- package/dist/ui/dist/assets/index-D74Skifa.css +1 -0
- package/dist/ui/dist/assets/index-Dnte9tPd.js +136 -0
- package/dist/ui/dist/assets/{info-OHQRW6UA-C3KneBKT.js → info-OHQRW6UA-D8oh6gvs.js} +1 -1
- package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-6wK09D32.js → infoDiagram-MVDRKAX5-BLSZjjgB.js} +1 -1
- package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DTP5C8VX.js → ishikawaDiagram-HPEHV2GE-BXiznLSn.js} +1 -1
- package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-B--UIMtq.js → journeyDiagram-BOSHW7O7-BSYmVcO_.js} +1 -1
- package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-Ku4N9QGt.js → kanban-definition-FNSA4HC5-COthkkzK.js} +1 -1
- package/dist/ui/dist/assets/{line-C4-H5h1-.js → line-6ND8OaOL.js} +1 -1
- package/dist/ui/dist/assets/{linear-DhAID3hm.js → linear-DbWEPFJX.js} +1 -1
- package/dist/ui/dist/assets/{mermaid-parser.core-CMPNc-u6.js → mermaid-parser.core-Btsk294z.js} +3 -3
- package/dist/ui/dist/assets/{mermaid.core-Nd_xOg2y.js → mermaid.core-B4llDSFO.js} +4 -4
- package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-BlVsym5h.js → mindmap-definition-6OY57UG3-hv2PfX-x.js} +1 -1
- package/dist/ui/dist/assets/{packet-JDAUHWVQ-rsk7SM7S.js → packet-JDAUHWVQ-Dr4vBgjt.js} +1 -1
- package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-C0L20M8z.js → pegDiagram-ECIUJNBX-DvlFov3N.js} +1 -1
- package/dist/ui/dist/assets/{pie-XZMESJXO-B23-iZSN.js → pie-XZMESJXO-C9QsqC4n.js} +1 -1
- package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-BqFhe4Oh.js → pieDiagram-GNMPY74T-CrrHOj67.js} +1 -1
- package/dist/ui/dist/assets/{prism-core-DYsiqJeR.js → prism-core-Cc8H7LPG.js} +1 -1
- package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-BKP75B-m.js → quadrantDiagram-D57K4MC3-DQ7J6-ti.js} +1 -1
- package/dist/ui/dist/assets/{radar-ABXABTNO-BrEk2mQ7.js → radar-ABXABTNO-BBBPaujh.js} +1 -1
- package/dist/ui/dist/assets/{railroad-I3PHUGI6-wWO4Eapc.js → railroad-I3PHUGI6-C-P6Igaz.js} +1 -1
- package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-DfY-IPvE.js → railroad-abnf-LNEFI6M7-CAkorRLs.js} +1 -1
- package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-19wedWGk.js → railroad-ebnf-DWYD2UWJ-D8Hz6gCf.js} +1 -1
- package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-9vIslnoJ.js → railroad-peg-JR7BVNR7-D-XNMkOs.js} +1 -1
- package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-DVnmXs8w.js → railroadDiagram-Y72NRBL7-DE9LwxJQ.js} +1 -1
- package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-BEoMdlzR.js → requirementDiagram-EQYSW5RA-FrKFY6NJ.js} +1 -1
- package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-D7Tn2Gbs.js → sankeyDiagram-SY24JHO5-BDxI6RDX.js} +1 -1
- package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-CyjUDBaQ.js → sequenceDiagram-BLEHNHQU-pV1XcKX7.js} +1 -1
- package/dist/ui/dist/assets/{src-BiC9XNcI.js → src-snNJTXXq.js} +1 -1
- package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-CMp1VCO9.js → stateDiagram-v2-A6IESKWZ-BSc_5aAf.js} +1 -1
- package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-KEciLd6r.js → swimlanes-B3ENRKFQ-DC6qUXLh.js} +1 -1
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-F1HykV3o.js +8 -0
- package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-C8YQtv7M.js → timeline-definition-JXYKGLGB-C8L0Dxtu.js} +1 -1
- package/dist/ui/dist/assets/{treeView-D4JQ5SDB-DgAzPaOO.js → treeView-D4JQ5SDB-DDyZIG5T.js} +1 -1
- package/dist/ui/dist/assets/{treemap-SAJKECNS-eCRqZMYc.js → treemap-SAJKECNS-CUaLgge0.js} +1 -1
- package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-Dirryw9G.js → usecaseDiagram-WPOLBNNX-BHRBd5pV.js} +1 -1
- package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-BjxQbAUx.js → vennDiagram-Q3Y3IDCW-Du4iwwkh.js} +1 -1
- package/dist/ui/dist/assets/{wardley-7MLQ67FV-CqHX5RUz.js → wardley-7MLQ67FV-DUgENI0s.js} +1 -1
- package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-Ds1HoEUY.js → wardleyDiagram-IVAIDKWA-B86zOdke.js} +1 -1
- package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-kodzDb12.js → xychartDiagram-4URNHDU4-twq9irqO.js} +1 -1
- package/dist/ui/dist/index.html +2 -2
- package/package.json +1 -1
- package/skills/follow-me/SKILL.md +5 -3
- package/dist/ui/dist/assets/channel-DPDY0XJP.js +0 -1
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-C1IBh_GZ.js +0 -1
- package/dist/ui/dist/assets/index-8BehwQ7E.js +0 -134
- package/dist/ui/dist/assets/index-mC2OW2Av.css +0 -1
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DzestpDE.js +0 -8
package/README.md
CHANGED
|
@@ -75,16 +75,24 @@ The red blob in the party hat is your branch tip. Nothing follow-me does moves i
|
|
|
75
75
|

|
|
76
76
|
*Cmd-click a name to jump to where it is defined, even in a later commit, and Alt+Left to come back. A definition the branch does not change opens as a peek.*
|
|
77
77
|
|
|
78
|
+
When your agent's answer names code, like `src/lib.js:4-5`, each place becomes a numbered chip. Hover the answer to tint those lines, click a chip to jump there and Alt+Left to come back. An answer that names several places is a walkthrough: press Enter on it, then `.` and `,` to step.
|
|
79
|
+
|
|
80
|
+

|
|
81
|
+
*A walkthrough. Each place the agent names is tinted and numbered on the code, the current step strongest, and the bar at the bottom says where you are.*
|
|
82
|
+
|
|
78
83
|

|
|
79
84
|
*Coverage while reading. Your agent runs the tests with coverage and passes the lcov to `follow-me coverage`; follow-me never runs anything. Bars show covered, partly covered and uncovered lines, `x` jumps to the next uncovered line, and clicking a bar lists the tests that ran it.*
|
|
80
85
|
|
|
86
|
+

|
|
87
|
+
*Your agent offers what it can draw, like "a state machine for this guard", as chips under the commit. Tap one and it answers with the diagram, each state marked by whether a test reached it. A transition is green with the number of tests that take it, or red and dashed when none does. When the tests run one at a time, the table names them, and a list names the sequences each step of which has a test but no one test drives. Click a state or a transition to jump to its code.*
|
|
88
|
+
|
|
81
89
|

|
|
82
90
|
*Diagrams sit full width under the commit or reply they explain, and open full size on click.*
|
|
83
91
|
|
|
84
92
|

|
|
85
93
|
*Submit review. Each item carries the original branch's path and line, so your agent can post it to the merge request as is.*
|
|
86
94
|
|
|
87
|
-
Keys: `[` shows or hides the index, `j` and `k` move between commits, Cmd-click (Ctrl-click) goes to a definition and Alt+Left comes back, arrows and Shift+arrows pick rows inside a hunk, `c` comments on the pick, `v` marks a commit viewed, `g` shows or hides coverage and `x` jumps to the next uncovered line, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
|
|
95
|
+
Keys: `[` shows or hides the index, `j` and `k` move between commits, Cmd-click (Ctrl-click) goes to a definition and Alt+Left comes back, arrows and Shift+arrows pick rows inside a hunk, `c` comments on the pick, `v` marks a commit viewed, `g` shows or hides coverage and `x` jumps to the next uncovered line, `.` and `,` step through the agent's walkthrough, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
|
|
88
96
|
|
|
89
97
|
### Reviewing someone else's branch
|
|
90
98
|
|
package/dist/src/cli.js
CHANGED
|
@@ -3,15 +3,17 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import {
|
|
6
|
+
import { mapOffTip, parseStateMap } from "./comments.js";
|
|
7
|
+
import { changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
|
|
7
8
|
import { definitionIndex, isIdentifier } from "./definition.js";
|
|
8
|
-
import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
|
|
9
|
-
import { BranchRefusedError, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
|
|
10
|
-
import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postCoverage, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
9
|
+
import { DiffParseError, formatUnitRef, isRepoPath, isUnitId, splitLines } from "./diff.js";
|
|
10
|
+
import { BranchRefusedError, blobsAt, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
|
|
11
|
+
import { fetchCarried, fetchComments, fetchReport, fetchWaitingOffers, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postCoverage, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
11
12
|
import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
|
|
12
13
|
import { naiveDraft } from "./naive.js";
|
|
13
|
-
import { isCommentId, isConcernId, isFields, serializePlanFile, validatePlan, } from "./plan.js";
|
|
14
|
+
import { isCommentId, isConcernId, isFields, OFFERS_PER_COMMIT, OFFERS_PER_PLAN, serializePlanFile, validatePlan, } from "./plan.js";
|
|
14
15
|
import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
|
|
16
|
+
import { parseRefText, refsIn, repoPathOfTyped, resolveRefs } from "./refs.js";
|
|
15
17
|
import { RegistrationError, sessionIdOf } from "./server.js";
|
|
16
18
|
import { BranchMovedError, cleanSessions, coverageAt, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
|
|
17
19
|
import { checkSyntax } from "./syntax.js";
|
|
@@ -31,19 +33,21 @@ prints what they flagged or asked. It is interactive: do what its "next:" line s
|
|
|
31
33
|
again until it prints the report.
|
|
32
34
|
|
|
33
35
|
Other commands:
|
|
34
|
-
reply <commentId> --body <text> [--diagram <file>]
|
|
36
|
+
reply <commentId> --body <text> [--diagram <file> [--map <file>]]
|
|
35
37
|
answer under a comment on the page; the diagram is
|
|
36
|
-
Mermaid source
|
|
38
|
+
Mermaid source, the map JSON tying its states to
|
|
39
|
+
tip lines
|
|
37
40
|
explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
|
|
38
41
|
start a thread on the page without being asked
|
|
39
|
-
show <h_…|c_…|k_…|path:
|
|
42
|
+
show <h_…|c_…|k_…|path:a[-b][@n|@base]|name>
|
|
40
43
|
print a unit, a comment's thread, a concern, the
|
|
41
|
-
lines around path:
|
|
44
|
+
lines around path:a-b in commit n, or where a name
|
|
42
45
|
is defined, instead of reading files
|
|
43
46
|
report [--json] [--markdown] print the report of the latest review; --json lists
|
|
44
47
|
the items to post to a merge request
|
|
45
|
-
coverage <lcov> [--test <id> [--at <path:line>]]
|
|
46
|
-
record a test run taken at the tip for the page
|
|
48
|
+
coverage <lcov> [--test <id> [--at <path:line>]] [--reset]
|
|
49
|
+
record a test run taken at the tip for the page;
|
|
50
|
+
--reset alone clears it before per-test runs
|
|
47
51
|
|
|
48
52
|
help --all lists every command.`;
|
|
49
53
|
const USAGE = `usage: follow-me <command> [options]
|
|
@@ -86,18 +90,20 @@ Interactive loop (while the review page is open):
|
|
|
86
90
|
reports the end of the review. Your own replies are
|
|
87
91
|
left out. --follow streams every event until the
|
|
88
92
|
review ends instead of stopping at the first
|
|
89
|
-
reply <commentId> --body <text> [--diagram <file>]
|
|
93
|
+
reply <commentId> --body <text> [--diagram <file> [--map <file>]]
|
|
90
94
|
answer under a comment on the page; the diagram is
|
|
91
|
-
Mermaid source
|
|
95
|
+
Mermaid source, the map JSON tying its states to
|
|
96
|
+
tip lines. \`path:a-b[@n]\` in backticks in the body
|
|
97
|
+
points at code, and is refused if it does not exist
|
|
92
98
|
explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
|
|
93
99
|
start a thread on the page without being asked;
|
|
94
100
|
--lines are change lines of the unit, commits count
|
|
95
101
|
from 1; with no target it is about the whole plan.
|
|
96
102
|
watch leaves these out like your replies
|
|
97
|
-
show <h_…|c_…|k_…|path:
|
|
103
|
+
show <h_…|c_…|k_…|path:a[-b][@n|@base]|name> [--branch <name>]
|
|
98
104
|
print a unit with the commits that take it, a
|
|
99
105
|
comment with its rows and thread, a concern with its
|
|
100
|
-
lines, the lines around path:
|
|
106
|
+
lines, the lines around path:a-b at the tip, in
|
|
101
107
|
commit n of the plan, or in the base, or each place
|
|
102
108
|
a name is defined at the tip with the commit it
|
|
103
109
|
lands in
|
|
@@ -107,11 +113,12 @@ Interactive loop (while the review page is open):
|
|
|
107
113
|
line in both voices, then questions with answers;
|
|
108
114
|
--json lists the items to post to a merge request
|
|
109
115
|
(path, lines, severity, body, thread, excluded)
|
|
110
|
-
coverage <lcov> [--test <id> [--at <path:line>]] [--branch <name>]
|
|
116
|
+
coverage <lcov> [--test <id> [--at <path:line>]] [--reset] [--branch <name>]
|
|
111
117
|
record the lcov of a test run at the branch tip;
|
|
112
118
|
refuses when a covered file in the working tree
|
|
113
119
|
differs from the tip. A plain run replaces the last;
|
|
114
|
-
--test runs add one test each, --at is where it is
|
|
120
|
+
--test runs add one test each, --at is where it is.
|
|
121
|
+
--reset starts fresh; alone it clears the last run
|
|
115
122
|
apply, while the page is open, swaps it to the new plan version in place, same URL.
|
|
116
123
|
|
|
117
124
|
Old names, still working:
|
|
@@ -291,6 +298,12 @@ function formatIssue(issue) {
|
|
|
291
298
|
return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
|
|
292
299
|
case "too-many-concerns":
|
|
293
300
|
return `${issue.unit} has more than one concern, in commits ${issue.commits.join(" and ")}; keep the one that matters`;
|
|
301
|
+
case "bad-offer":
|
|
302
|
+
return `$.commits[${issue.commit}]: offer: ${issue.reason}`;
|
|
303
|
+
case "too-many-offers":
|
|
304
|
+
return issue.commit === null
|
|
305
|
+
? `the plan has ${issue.count} offers; at most ${OFFERS_PER_PLAN}, and most plans need none`
|
|
306
|
+
: `$.commits[${issue.commit}] has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
|
|
294
307
|
case "final-mismatch": {
|
|
295
308
|
const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
|
|
296
309
|
return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
|
|
@@ -305,6 +318,9 @@ function formatWarning(w) {
|
|
|
305
318
|
if (w.kind === "long-title") {
|
|
306
319
|
return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
|
|
307
320
|
}
|
|
321
|
+
if (w.kind === "section-order") {
|
|
322
|
+
return `warning: $.commits[${w.commit}]: section ${w.section} comes after ${w.after}; sections read story, steps, details, wiring`;
|
|
323
|
+
}
|
|
308
324
|
if (w.kind === "question-addressed") {
|
|
309
325
|
return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
|
|
310
326
|
}
|
|
@@ -1053,6 +1069,13 @@ function waitOf(args) {
|
|
|
1053
1069
|
/** One call fits inside a harness's tool timeout, so the agent's loop is one command per turn. */
|
|
1054
1070
|
const ENTRY_WAIT_SECONDS = 90;
|
|
1055
1071
|
const MAX_ENTRY_WAIT_SECONDS = 300;
|
|
1072
|
+
/** The step that names the oldest accepted offer the agent has not answered, or null. */
|
|
1073
|
+
async function offerStep(port, summary) {
|
|
1074
|
+
const [first] = await fetchWaitingOffers(port, summary);
|
|
1075
|
+
if (first === undefined)
|
|
1076
|
+
return null;
|
|
1077
|
+
return `${STILL_WORKING}; offer ${first.offer} waits for your diagram: follow-me reply ${first.comment} --diagram <file> [--map <file>], then run follow-me again`;
|
|
1078
|
+
}
|
|
1056
1079
|
/**
|
|
1057
1080
|
* Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
|
|
1058
1081
|
* returns at once with news, and with the report when the reviewer submitted or ended the review.
|
|
@@ -1112,11 +1135,13 @@ async function listen(session, open, args) {
|
|
|
1112
1135
|
next("done; the user can port it with follow-me report --json");
|
|
1113
1136
|
return 0;
|
|
1114
1137
|
}
|
|
1115
|
-
next(
|
|
1138
|
+
next((await offerStep(port, summary)) ??
|
|
1139
|
+
`${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
|
|
1116
1140
|
return 0;
|
|
1117
1141
|
}
|
|
1118
1142
|
if (left <= 0) {
|
|
1119
|
-
next(
|
|
1143
|
+
next((await offerStep(port, summary)) ??
|
|
1144
|
+
`${STILL_WORKING}; run follow-me again to keep listening`);
|
|
1120
1145
|
return 0;
|
|
1121
1146
|
}
|
|
1122
1147
|
}
|
|
@@ -1315,7 +1340,7 @@ async function report(cwd, args, walkOnly = false) {
|
|
|
1315
1340
|
process.stdout.write(text);
|
|
1316
1341
|
return 0;
|
|
1317
1342
|
}
|
|
1318
|
-
const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]]";
|
|
1343
|
+
const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset";
|
|
1319
1344
|
/**
|
|
1320
1345
|
* Records the lcov of a test run the agent made at the tip in the session's coverage.json and
|
|
1321
1346
|
* prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
|
|
@@ -1324,6 +1349,13 @@ async function coverage(cwd, args) {
|
|
|
1324
1349
|
const [file, extra] = args.positionals;
|
|
1325
1350
|
const id = args.values.get("test");
|
|
1326
1351
|
const atArg = args.values.get("at");
|
|
1352
|
+
const reset = args.flags.has("reset");
|
|
1353
|
+
if (file === undefined && reset && id === undefined && atArg === undefined) {
|
|
1354
|
+
const session = await openFresh(cwd, args);
|
|
1355
|
+
session.clearCoverage();
|
|
1356
|
+
say("coverage cleared; the next run starts fresh");
|
|
1357
|
+
return 0;
|
|
1358
|
+
}
|
|
1327
1359
|
if (file === undefined || extra !== undefined)
|
|
1328
1360
|
throw new UsageError(COVERAGE_USAGE);
|
|
1329
1361
|
if (atArg !== undefined && id === undefined) {
|
|
@@ -1354,10 +1386,15 @@ async function coverage(cwd, args) {
|
|
|
1354
1386
|
const at = atArg === undefined ? undefined : parseLocArg(atArg, place);
|
|
1355
1387
|
if (typeof at === "string")
|
|
1356
1388
|
throw new UsageError(at);
|
|
1357
|
-
|
|
1389
|
+
const preview = at === undefined ? undefined : await testPreview(session, at);
|
|
1390
|
+
test = {
|
|
1391
|
+
id,
|
|
1392
|
+
...(at === undefined ? {} : { at }),
|
|
1393
|
+
...(preview === undefined ? {} : { preview }),
|
|
1394
|
+
};
|
|
1358
1395
|
}
|
|
1359
1396
|
const takenAt = new Date().toISOString();
|
|
1360
|
-
const recorded = recordRun(session.coverage(), run, {
|
|
1397
|
+
const recorded = recordRun(reset ? null : session.coverage(), run, {
|
|
1361
1398
|
tip,
|
|
1362
1399
|
takenAt,
|
|
1363
1400
|
...(test === undefined ? {} : { test }),
|
|
@@ -1370,6 +1407,19 @@ async function coverage(cwd, args) {
|
|
|
1370
1407
|
say(`coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`);
|
|
1371
1408
|
return 0;
|
|
1372
1409
|
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Up to PREVIEW_LINES tip lines from where a test starts, when the branch leaves its file alone:
|
|
1412
|
+
* the page shows the branch's own files from the plan, and the others need this to peek.
|
|
1413
|
+
*/
|
|
1414
|
+
async function testPreview(session, at) {
|
|
1415
|
+
const changed = new Set([...session.diff.units.values()].map((u) => u.path));
|
|
1416
|
+
if (changed.has(at.path))
|
|
1417
|
+
return undefined;
|
|
1418
|
+
const bytes = (await blobsAt(session.root, session.anchor.tip, [at.path])).get(at.path) ?? "";
|
|
1419
|
+
return splitLines(bytes)
|
|
1420
|
+
.slice(at.start - 1, at.start - 1 + PREVIEW_LINES)
|
|
1421
|
+
.map((line) => Buffer.from(line.replace(/\r?\n$/, ""), "latin1").toString("utf8"));
|
|
1422
|
+
}
|
|
1373
1423
|
/** The old name of report. */
|
|
1374
1424
|
async function feedback(cwd, args) {
|
|
1375
1425
|
console.error("follow-me: feedback is now report; printing the same");
|
|
@@ -1380,11 +1430,13 @@ function isCommentEvent(event) {
|
|
|
1380
1430
|
}
|
|
1381
1431
|
/**
|
|
1382
1432
|
* Events the reviewer caused; the agent's own replies, explanations, version swaps and coverage
|
|
1383
|
-
* are not news.
|
|
1433
|
+
* are not news, and neither is a dismissed offer, which asks nothing of the agent.
|
|
1384
1434
|
*/
|
|
1385
1435
|
function isNews(event) {
|
|
1386
1436
|
if (event.type === "version" || event.type === "coverage")
|
|
1387
1437
|
return false;
|
|
1438
|
+
if (event.type === "offer-dismissed")
|
|
1439
|
+
return false;
|
|
1388
1440
|
if (event.type === "comment" || event.type === "comment-updated") {
|
|
1389
1441
|
return event.comment.author !== "agent";
|
|
1390
1442
|
}
|
|
@@ -1464,24 +1516,107 @@ async function watch(cwd, args) {
|
|
|
1464
1516
|
/** Posts the agent's answer under a comment on the hosted page. */
|
|
1465
1517
|
async function reply(cwd, args) {
|
|
1466
1518
|
const [commentId, extra] = args.positionals;
|
|
1467
|
-
const
|
|
1519
|
+
const diagram = diagramOf(args);
|
|
1520
|
+
// A diagram can be the whole answer, as when it answers an accepted offer.
|
|
1521
|
+
const body = args.values.get("body") ?? (diagram === undefined ? undefined : "");
|
|
1468
1522
|
if (commentId === undefined || extra !== undefined || body === undefined) {
|
|
1469
|
-
throw new UsageError(
|
|
1523
|
+
throw new UsageError(REPLY_USAGE);
|
|
1524
|
+
}
|
|
1525
|
+
const mapFile = args.values.get("map");
|
|
1526
|
+
if (mapFile !== undefined && diagram === undefined) {
|
|
1527
|
+
throw new UsageError(`--map needs --diagram, the state diagram it ties to code\n${REPLY_USAGE}`);
|
|
1470
1528
|
}
|
|
1471
|
-
const diagram = diagramOf(args);
|
|
1472
1529
|
const session = await openSession(cwd, sessionOpts(args));
|
|
1530
|
+
const map = mapFile === undefined ? undefined : await readMap(session, mapFile, diagram);
|
|
1473
1531
|
const open = await openOnServer(session, null);
|
|
1474
1532
|
if (open === null)
|
|
1475
1533
|
return notOpen(session);
|
|
1534
|
+
const refs = await bodyRefs(session, open.summary.version, body);
|
|
1476
1535
|
const id = await postReply(open.port, open.summary, commentId, {
|
|
1477
1536
|
author: "agent",
|
|
1478
1537
|
body,
|
|
1479
1538
|
...(diagram === undefined ? {} : { diagram }),
|
|
1539
|
+
...(map === undefined ? {} : { map }),
|
|
1540
|
+
...(refs.length === 0 ? {} : { refs }),
|
|
1480
1541
|
});
|
|
1481
1542
|
say(`replied ${id} to ${commentId}`);
|
|
1482
1543
|
next(`run follow-me again; ${STILL_WORKING}`);
|
|
1483
1544
|
return 0;
|
|
1484
1545
|
}
|
|
1546
|
+
const REPLY_USAGE = `usage: follow-me reply <commentId> --body <text> [--diagram <file> [--map <file>]]
|
|
1547
|
+
follow-me reply <commentId> --diagram <file> [--map <file>] [--body <text>]`;
|
|
1548
|
+
/** A map path as git names it: the JSON's UTF-8 text as the bytes git prints, read as latin1. */
|
|
1549
|
+
function repoPathOf(typed) {
|
|
1550
|
+
const path = Buffer.from(typed, "utf8").toString("latin1");
|
|
1551
|
+
if (!isRepoPath(path))
|
|
1552
|
+
throw new UsageError(`${typed} is not a repo path`);
|
|
1553
|
+
return path;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* --map's JSON, checked against the tip: every place is a file there and within its lines. Every
|
|
1557
|
+
* bad entry is named at once, so one fix round is enough.
|
|
1558
|
+
*/
|
|
1559
|
+
async function readMap(session, file, diagram) {
|
|
1560
|
+
let raw;
|
|
1561
|
+
try {
|
|
1562
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
1563
|
+
}
|
|
1564
|
+
catch (error) {
|
|
1565
|
+
throw new UsageError(`cannot read ${file} as JSON: ${error instanceof Error ? error.message : error}`);
|
|
1566
|
+
}
|
|
1567
|
+
const typed = parseStateMap(raw, diagram);
|
|
1568
|
+
if (typeof typed === "string")
|
|
1569
|
+
throw new UsageError(`${file}: ${typed}`);
|
|
1570
|
+
const at = (loc) => ({ ...loc, path: repoPathOf(loc.path) });
|
|
1571
|
+
const map = {
|
|
1572
|
+
states: Object.fromEntries(Object.entries(typed.states).map(([name, locs]) => [name, locs.map(at)])),
|
|
1573
|
+
...(typed.transitions === undefined
|
|
1574
|
+
? {}
|
|
1575
|
+
: { transitions: typed.transitions.map((t) => ({ ...t, at: t.at.map(at) })) }),
|
|
1576
|
+
};
|
|
1577
|
+
const { root, anchor } = session;
|
|
1578
|
+
const named = [
|
|
1579
|
+
...Object.values(map.states).flat(),
|
|
1580
|
+
...(map.transitions ?? []).flatMap((t) => t.at),
|
|
1581
|
+
].map((loc) => loc.path);
|
|
1582
|
+
const tip = await pathsAt(root, anchor.tip);
|
|
1583
|
+
const present = [...new Set(named)].filter((p) => tip.has(p));
|
|
1584
|
+
const lines = new Map([...(await blobsAt(root, anchor.tip, present))].map(([p, bytes]) => [
|
|
1585
|
+
p,
|
|
1586
|
+
splitLines(bytes).length,
|
|
1587
|
+
]));
|
|
1588
|
+
const problems = mapOffTip(map, lines, (p) => Buffer.from(p, "latin1").toString("utf8"));
|
|
1589
|
+
if (problems.length > 0)
|
|
1590
|
+
throw new UsageError(`${file}:\n ${problems.join("\n ")}`);
|
|
1591
|
+
return map;
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* The refs --body writes, resolved against plan `version` and the tip. Every one that does not
|
|
1595
|
+
* resolve is named at once, as --map's are.
|
|
1596
|
+
*/
|
|
1597
|
+
async function bodyRefs(session, version, body) {
|
|
1598
|
+
const specs = refsIn(body);
|
|
1599
|
+
if (specs.length === 0)
|
|
1600
|
+
return [];
|
|
1601
|
+
const { root, diff } = session;
|
|
1602
|
+
const otherChanged = new Set([...diff.units.values()].flatMap((u) => (diff.textFiles.has(u.path) ? [] : [u.path])));
|
|
1603
|
+
const asked = [...new Set(specs.map((s) => repoPathOfTyped(s.path)))].filter((p) => isRepoPath(p) && !diff.textFiles.has(p) && !otherChanged.has(p));
|
|
1604
|
+
const tip = asked.length === 0 ? new Set() : await pathsAt(root, diff.tip);
|
|
1605
|
+
const present = asked.filter((p) => tip.has(p));
|
|
1606
|
+
const unchanged = new Map([...(await blobsAt(root, diff.tip, present))].map(([p, bytes]) => [p, splitLines(bytes)]));
|
|
1607
|
+
const { refs, problems } = resolveRefs(specs, {
|
|
1608
|
+
tip: diff.tip,
|
|
1609
|
+
textFiles: diff.textFiles,
|
|
1610
|
+
otherChanged,
|
|
1611
|
+
unchanged,
|
|
1612
|
+
commits: projectedCommits(session, version),
|
|
1613
|
+
version,
|
|
1614
|
+
});
|
|
1615
|
+
if (problems.length > 0) {
|
|
1616
|
+
throw new UsageError(`--body points at code that does not resolve:\n ${problems.join("\n ")}`);
|
|
1617
|
+
}
|
|
1618
|
+
return refs;
|
|
1619
|
+
}
|
|
1485
1620
|
/** The Mermaid source in --diagram's file, or undefined without the flag. */
|
|
1486
1621
|
function diagramOf(args) {
|
|
1487
1622
|
const file = args.values.get("diagram");
|
|
@@ -1589,17 +1724,19 @@ async function explain(cwd, args) {
|
|
|
1589
1724
|
if (open === null)
|
|
1590
1725
|
return notOpen(session);
|
|
1591
1726
|
const target = explainTarget(session, open.summary.version, args);
|
|
1727
|
+
const refs = await bodyRefs(session, open.summary.version, body);
|
|
1592
1728
|
const id = await postComment(open.port, open.summary, {
|
|
1593
1729
|
...target,
|
|
1594
1730
|
body,
|
|
1595
1731
|
author: "agent",
|
|
1596
1732
|
...(diagram === undefined ? {} : { diagram }),
|
|
1733
|
+
...(refs.length === 0 ? {} : { refs }),
|
|
1597
1734
|
});
|
|
1598
1735
|
say(`explained ${id}`);
|
|
1599
1736
|
next(`run follow-me again; ${STILL_WORKING}`);
|
|
1600
1737
|
return 0;
|
|
1601
1738
|
}
|
|
1602
|
-
const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:
|
|
1739
|
+
const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:a[-b][@<commit>|@base]|name>";
|
|
1603
1740
|
/** Every comment the session has, with the plan version it was made on: live ones first. */
|
|
1604
1741
|
async function knownComments(session) {
|
|
1605
1742
|
const open = await openOnServer(session, null);
|
|
@@ -1613,14 +1750,16 @@ async function knownComments(session) {
|
|
|
1613
1750
|
}
|
|
1614
1751
|
const SHOW_MARGIN = 3;
|
|
1615
1752
|
/**
|
|
1616
|
-
* The lines around path:
|
|
1617
|
-
* base, or the tip when `at` is
|
|
1753
|
+
* The lines around path:a-b as commit `at` of the latest plan writes the file (1-based), the
|
|
1754
|
+
* base, or the tip when `at` is null. Read from the plan, so it works before any apply.
|
|
1618
1755
|
*/
|
|
1619
1756
|
function showLines(session, raw) {
|
|
1620
|
-
const
|
|
1621
|
-
|
|
1622
|
-
if (typed === undefined || lineRaw === undefined)
|
|
1757
|
+
const spec = parseRefText(raw);
|
|
1758
|
+
if (spec === null)
|
|
1623
1759
|
throw new UsageError(SHOW_USAGE);
|
|
1760
|
+
const { path: typed, start, end, at } = spec;
|
|
1761
|
+
if (end < start)
|
|
1762
|
+
throw new UsageError(`${raw} ends before it starts`);
|
|
1624
1763
|
const path = Buffer.from(typed, "utf8").toString("latin1");
|
|
1625
1764
|
const file = [...session.diff.textFiles.values()].find((f) => f.path === path);
|
|
1626
1765
|
if (file === undefined) {
|
|
@@ -1628,7 +1767,7 @@ function showLines(session, raw) {
|
|
|
1628
1767
|
}
|
|
1629
1768
|
let text;
|
|
1630
1769
|
let label;
|
|
1631
|
-
if (at ===
|
|
1770
|
+
if (at === null) {
|
|
1632
1771
|
text = file.tip?.bytes ?? null;
|
|
1633
1772
|
label = "the tip";
|
|
1634
1773
|
}
|
|
@@ -1639,7 +1778,7 @@ function showLines(session, raw) {
|
|
|
1639
1778
|
else {
|
|
1640
1779
|
const latest = session.plans().at(-1);
|
|
1641
1780
|
const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
|
|
1642
|
-
const n =
|
|
1781
|
+
const n = at;
|
|
1643
1782
|
if (n < 1 || n > commits.length) {
|
|
1644
1783
|
throw new UsageError(`@${at} is not one of the ${commits.length} commits of the plan`);
|
|
1645
1784
|
}
|
|
@@ -1658,18 +1797,18 @@ function showLines(session, raw) {
|
|
|
1658
1797
|
const lines = Buffer.from(text, "latin1").toString("utf8").split("\n");
|
|
1659
1798
|
if (lines.at(-1) === "")
|
|
1660
1799
|
lines.pop();
|
|
1661
|
-
|
|
1662
|
-
if (line < 1 || line > lines.length) {
|
|
1800
|
+
if (start < 1 || end > lines.length) {
|
|
1663
1801
|
return `${typed} has ${plural(lines.length, "line")} in ${label}\n`;
|
|
1664
1802
|
}
|
|
1665
|
-
const from = Math.max(1,
|
|
1666
|
-
const to = Math.min(lines.length,
|
|
1803
|
+
const from = Math.max(1, start - SHOW_MARGIN);
|
|
1804
|
+
const to = Math.min(lines.length, end + SHOW_MARGIN);
|
|
1667
1805
|
const width = String(to).length;
|
|
1668
1806
|
const rows = lines.slice(from - 1, to).map((l, i) => {
|
|
1669
1807
|
const n = from + i;
|
|
1670
|
-
return `${n
|
|
1808
|
+
return `${n >= start && n <= end ? ">" : " "} ${String(n).padStart(width)} ${l}`;
|
|
1671
1809
|
});
|
|
1672
|
-
|
|
1810
|
+
const span = end > start ? `${start}-${end}` : `${start}`;
|
|
1811
|
+
return `${typed}:${span} in ${label}\n${rows.join("\n")}\n`;
|
|
1673
1812
|
}
|
|
1674
1813
|
/** Expands a ref the agent was given to the lines and thread it names, so no file read is needed. */
|
|
1675
1814
|
async function showRef(cwd, args) {
|
|
@@ -1950,7 +2089,7 @@ export async function main(argv, cwd) {
|
|
|
1950
2089
|
return await watch(cwd, parseArgs(rest, spec));
|
|
1951
2090
|
}
|
|
1952
2091
|
case "reply":
|
|
1953
|
-
return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "branch"] }));
|
|
2092
|
+
return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "map", "branch"] }));
|
|
1954
2093
|
case "explain": {
|
|
1955
2094
|
const spec = { values: ["commit", "unit", "lines", "body", "diagram", "branch"] };
|
|
1956
2095
|
return await explain(cwd, parseArgs(rest, spec));
|
|
@@ -1958,7 +2097,7 @@ export async function main(argv, cwd) {
|
|
|
1958
2097
|
case "show":
|
|
1959
2098
|
return await showRef(cwd, parseArgs(rest, { values: ["branch"] }));
|
|
1960
2099
|
case "coverage":
|
|
1961
|
-
return await coverage(cwd, parseArgs(rest, { values: ["test", "at", "branch"] }));
|
|
2100
|
+
return await coverage(cwd, parseArgs(rest, { values: ["test", "at", "branch"], flags: ["reset"] }));
|
|
1962
2101
|
case "report":
|
|
1963
2102
|
return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
|
|
1964
2103
|
case "clean": {
|