toolroll 0.9.102 → 0.9.103
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 +5 -0
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +8 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/flow-engine.d.ts +3 -0
- package/dist/flow-engine.js +4 -1
- package/dist/flows-ui.d.ts +31 -1
- package/dist/flows-ui.js +40 -12
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/release-approval.d.ts +9 -20
- package/dist/release-approval.js +14 -53
- package/dist/release-refusal.d.ts +32 -0
- package/dist/release-refusal.js +73 -0
- package/dist/scope.js +6 -0
- package/dist/server/home-asks.d.ts +45 -0
- package/dist/server/home-asks.js +108 -0
- package/dist/server/home.d.ts +15 -5
- package/dist/server/home.js +53 -18
- package/dist/server/pages.js +15 -3
- package/dist/work-index.d.ts +10 -5
- package/dist/work-index.js +23 -13
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What else waits on a person at Home (docs/plans/home-other-asks.md): a release's line and a held publication, a flow
|
|
3
|
+
* card waiting on them, and the first-run steps left to do. Reads only, each through the same admission as the page it
|
|
4
|
+
* links to; nothing here acts: every row's one link opens its own page, whose own checks decide what this person may do
|
|
5
|
+
* there. `homeView` (home.ts) assembles these with the work index's rows.
|
|
6
|
+
*/
|
|
7
|
+
import { flowCardHref, flowDefinitionOf } from "../flow-engine.js";
|
|
8
|
+
import { browserViewOf } from "../flow-browser.js";
|
|
9
|
+
import { flowCardWaitsOn } from "../flows-ui.js";
|
|
10
|
+
import { releaseAskOf, releaseTaskOf } from "../release-approval.js";
|
|
11
|
+
import { autoPublishHeld, PUBLISH_ALLOWED_ACTION, PUBLISH_HELD_ACTION } from "../release-auto.js";
|
|
12
|
+
import { taskArchived } from "../task-archive.js";
|
|
13
|
+
import { workStateRows } from "../work-index.js";
|
|
14
|
+
export const NO_HOME_ASKS = { release: () => null, held: [], flows: [], setup: [] };
|
|
15
|
+
/** The release sheet: where a release is read and approved, never Home. */
|
|
16
|
+
export const releaseSheetHref = (taskId) => `/t/${encodeURIComponent(taskId)}/release`;
|
|
17
|
+
const REVIEW_RELEASE = "Review the release";
|
|
18
|
+
/** A release's line (its version, or the sheet's refusal) and every held publication in view. Approvers only: the sheet
|
|
19
|
+
* mints its approval only for them; anyone else reads a release as an ordinary task row. */
|
|
20
|
+
export function releaseAsks(store, now, access, project, approver) {
|
|
21
|
+
if (!approver)
|
|
22
|
+
return { release: () => null, held: [] };
|
|
23
|
+
const release = (taskId) => {
|
|
24
|
+
const ask = releaseAskOf(store, taskId);
|
|
25
|
+
return ask === null ? null : ask.refusal ?? `Version ${ask.version} waits for your approval.`;
|
|
26
|
+
};
|
|
27
|
+
const shown = (repo) => (access.repos === null || access.repos.includes(repo)) && (project === null || repo === project);
|
|
28
|
+
// Only what is still held: the newest held-or-allowed act per task is the held one (release-auto.ts autoPublishHeld's
|
|
29
|
+
// own rule, read in one statement). Each is then a release this person may see in this scope, before anything heavier.
|
|
30
|
+
const marked = store.handle.prepare(`SELECT a.task_id, a.at, a.detail FROM action_ledger a WHERE a.action = $held AND a.task_id IS NOT NULL
|
|
31
|
+
AND a.id = (SELECT MAX(b.id) FROM action_ledger b WHERE b.task_id = a.task_id AND b.action IN ($held, $allowed)) ORDER BY a.at DESC, a.id DESC`)
|
|
32
|
+
.all({ $held: PUBLISH_HELD_ACTION, $allowed: PUBLISH_ALLOWED_ACTION });
|
|
33
|
+
const candidates = marked.flatMap(row => {
|
|
34
|
+
const taskId = String(row["task_id"]);
|
|
35
|
+
const found = releaseTaskOf(store, taskId);
|
|
36
|
+
return found === null || !shown(found.ref.repo) || taskArchived(store, taskId) ? []
|
|
37
|
+
: [{ taskId, ref: found.ref.id, repo: found.ref.repo, at: String(row["at"]), why: autoPublishHeld(store, taskId) ?? String(row["detail"] ?? "") }];
|
|
38
|
+
});
|
|
39
|
+
if (candidates.length === 0)
|
|
40
|
+
return { release, held: [] };
|
|
41
|
+
// Every family in this person's view, read once (the Tasks list's own projection): a held release's family that is
|
|
42
|
+
// already a row that needs them (in any group) is not listed twice, and one the view doesn't admit is not listed.
|
|
43
|
+
const familyOf = new Map();
|
|
44
|
+
for (const family of workStateRows(store, now, access))
|
|
45
|
+
for (const member of family.members)
|
|
46
|
+
familyOf.set(member, { rootId: family.rootId, needs: family.needs });
|
|
47
|
+
const families = new Set();
|
|
48
|
+
const held = [];
|
|
49
|
+
for (const one of candidates) {
|
|
50
|
+
const family = familyOf.get(one.ref);
|
|
51
|
+
if (family === undefined || family.needs || families.has(family.rootId))
|
|
52
|
+
continue;
|
|
53
|
+
families.add(family.rootId);
|
|
54
|
+
held.push({ id: `release:${family.rootId}`, title: store.getTask(one.taskId)?.title ?? one.taskId, href: releaseSheetHref(one.taskId), repo: one.repo, at: one.at,
|
|
55
|
+
line: one.why, action: { label: REVIEW_RELEASE, href: releaseSheetHref(one.taskId) } });
|
|
56
|
+
}
|
|
57
|
+
return { release, held };
|
|
58
|
+
}
|
|
59
|
+
/** Every active card, in a flow of a project this person may see, that waits on them (flows-ui.ts flowCardWaitsOn, the
|
|
60
|
+
* flow page's own rule). Newest wait first. */
|
|
61
|
+
export function flowAsks(store, repos, viewer, project) {
|
|
62
|
+
if (!viewer.approver)
|
|
63
|
+
return [];
|
|
64
|
+
const rows = [];
|
|
65
|
+
for (const flow of store.listFlows(repos)) {
|
|
66
|
+
if (flow.state !== "active" || (project !== null && flow.repo !== project) || !store.accountCanAccess(viewer.name, flow.repo))
|
|
67
|
+
continue;
|
|
68
|
+
const stages = flowDefinitionOf(flow)?.stages ?? [];
|
|
69
|
+
// The flow's browser standing, read once and only when a card waits in a zone that starts a browser.
|
|
70
|
+
let browser;
|
|
71
|
+
for (const card of store.flowCards(flow.id, false)) {
|
|
72
|
+
if (card.state !== "active")
|
|
73
|
+
continue;
|
|
74
|
+
// A card whose zone was removed still has its subagent's open question (the flow page still takes the answer).
|
|
75
|
+
const stage = stages.find(one => one.id === card.stage);
|
|
76
|
+
if (stage?.kind === "check" && stage.browser === true && browser === undefined)
|
|
77
|
+
browser = browserViewOf(store, flow);
|
|
78
|
+
const waits = flowCardWaitsOn(store, flow, card, viewer, { stage, ...(browser === undefined ? {} : { browser }) });
|
|
79
|
+
if (!waits.decide && !waits.choose && !waits.answer && !waits.browser && !waits.reply)
|
|
80
|
+
continue;
|
|
81
|
+
const asked = waits.answer ? store.openSubagentQuestionOn(card.id, card.entry)?.question ?? null : null;
|
|
82
|
+
// The card's own waiting words (a browser wait without the command the page offers as a button).
|
|
83
|
+
const waiting = card.waiting?.replace(/ Approve it with: toolroll flows browser \d+ on$/, "") ?? null;
|
|
84
|
+
const said = waiting ?? asked ?? (waits.choose ? "Waits for your choice." : "Waits for your decision.");
|
|
85
|
+
const href = flowCardHref(flow.id, card.id);
|
|
86
|
+
rows.push({ id: `flow:${card.id}`, title: card.title, href, repo: flow.repo, at: store.flowCardEnteredAt(card.id) ?? card.updatedAt,
|
|
87
|
+
line: `${flow.name}: ${said}`, action: { label: "Open the card", href } });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return rows.sort((a, b) => (b.at ?? "").localeCompare(a.at ?? ""));
|
|
91
|
+
}
|
|
92
|
+
const SETUP_TITLES = {
|
|
93
|
+
agent: "Sign in to a coding agent", project: "Add a project", builder: "Connect a builder", task: "File your first task",
|
|
94
|
+
};
|
|
95
|
+
/** The first-run steps (first-run.ts, with the builder step) still to do, for an approver. A step that is done, or only
|
|
96
|
+
* being checked (no answer yet, so no action), is not a row. Null steps (a restricted console, the demo, after the first
|
|
97
|
+
* success) are none. */
|
|
98
|
+
export function setupAsks(steps, approver) {
|
|
99
|
+
if (steps === null || !approver)
|
|
100
|
+
return [];
|
|
101
|
+
return steps.flatMap(step => {
|
|
102
|
+
if (step.done || step.checking === true || step.action === null)
|
|
103
|
+
return [];
|
|
104
|
+
const action = step.action.kind === "link" ? { label: step.action.label, href: step.action.href } : { label: "Open setup", href: "/welcome" };
|
|
105
|
+
return [{ key: step.key, id: `setup:${step.key}`, title: SETUP_TITLES[step.key], href: action.href, repo: null, at: null,
|
|
106
|
+
line: step.action.kind === "command" ? `Run ${step.action.command} in a terminal.` : null, action }];
|
|
107
|
+
});
|
|
108
|
+
}
|
package/dist/server/home.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Home ("/", docs/plans/live-pages-and-home.md B1): what needs this person, grouped by what is asked
|
|
3
|
-
*
|
|
4
|
-
* count; one quiet line for what runs and waits; with nothing waiting, what runs and what finished today. The figures
|
|
2
|
+
* Home ("/", docs/plans/live-pages-and-home.md B1, home-other-asks.md): what needs this person, grouped by what is asked
|
|
3
|
+
* (Approve a release, Approve a plan, Answer a question, Flow decisions, Something went wrong, Review results, Finish
|
|
4
|
+
* setting up, and Needs you for the rest), each group with its exact count; one quiet line for what runs and waits; with nothing waiting, what runs and what finished today. The figures
|
|
5
5
|
* are read from the same work index the Tasks list filters, so a count and the list it opens always agree. Reads
|
|
6
6
|
* only: every act keeps its own route and rules (an agent's question is answered through POST /d/<id>/answer, the
|
|
7
7
|
* same one-click act as its own page).
|
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
import type { BrowserHomeAsk, BrowserHomeView } from "../browser-workspace.js";
|
|
10
10
|
import type { Store } from "../store.js";
|
|
11
11
|
import type { WorkSummaryAccess } from "../work-summary.js";
|
|
12
|
+
import { type HomeAsks } from "./home-asks.js";
|
|
12
13
|
/** How many waiting rows Home lists; each group says how many more open in Tasks. */
|
|
13
14
|
export declare const HOME_NEEDS_SHOWN = 30;
|
|
14
15
|
/** How many rows finished today Home lists; its figure counts them all. */
|
|
15
16
|
export declare const HOME_DONE_SHOWN = 8;
|
|
16
17
|
/** Each group's heading, in the order Home shows them. */
|
|
17
18
|
export declare const HOME_ASK_LABEL: Readonly<Record<BrowserHomeAsk, string>>;
|
|
19
|
+
/** The groups in Home's one order. */
|
|
20
|
+
export declare const HOME_ASK_ORDER: readonly BrowserHomeAsk[];
|
|
18
21
|
export type HomeFacts = {
|
|
19
22
|
store: Store;
|
|
20
23
|
now: Date;
|
|
@@ -31,6 +34,11 @@ export type HomeFacts = {
|
|
|
31
34
|
chat: boolean;
|
|
32
35
|
/** This person can answer an agent's question (an approver in a browser session). */
|
|
33
36
|
canAnswer: boolean;
|
|
37
|
+
/** An approver in a browser session (default: `canAnswer`): a release is then read in Approve a release, never
|
|
38
|
+
* approved here; for anyone else it stays an ordinary task row. */
|
|
39
|
+
approver?: boolean;
|
|
40
|
+
/** What else waits on this person (home-asks.ts), already read through each page's own admission. */
|
|
41
|
+
asks?: HomeAsks;
|
|
34
42
|
/** Why this task's plan can't take a yes right now (render-tasks.ts consentDoorOf), or null when it can. */
|
|
35
43
|
closedDoor: (taskId: string) => {
|
|
36
44
|
title: string;
|
|
@@ -44,8 +52,10 @@ export type HomeFacts = {
|
|
|
44
52
|
at: string;
|
|
45
53
|
} | null;
|
|
46
54
|
};
|
|
47
|
-
/** "19 tasks need you"
|
|
48
|
-
|
|
55
|
+
/** "19 tasks need you", "3 tasks and 2 other things need you", "2 things need you": the heading, said once. Tasks
|
|
56
|
+
* are the same total as the rail's count; `others` are the rows that aren't tasks (a held publication, a flow card, a
|
|
57
|
+
* setup step). */
|
|
58
|
+
export declare function homeSentence(needs: number, any: boolean, others?: number): string;
|
|
49
59
|
/** The lead's words as one plain line: no markdown marks, no line breaks, cut to about one line. */
|
|
50
60
|
export declare function leadLineOf(text: string): string;
|
|
51
61
|
/** Midnight today on this computer's clock: "finished today" starts here. */
|
package/dist/server/home.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { browserWorkActionHref } from "../browser-crew.js";
|
|
2
2
|
import { NO_REASON_RECORDED } from "../needs-you.js";
|
|
3
3
|
import { projectName } from "../project.js";
|
|
4
|
-
import {
|
|
4
|
+
import { workIndexPage } from "../work-index.js";
|
|
5
5
|
import { oneLineOf, relativeAge } from "./chrome.js";
|
|
6
6
|
import { taskHref } from "./http.js";
|
|
7
|
+
import { NO_HOME_ASKS, releaseSheetHref } from "./home-asks.js";
|
|
7
8
|
/** How many waiting rows Home lists; each group says how many more open in Tasks. */
|
|
8
9
|
export const HOME_NEEDS_SHOWN = 30;
|
|
9
10
|
const HOME_RUNNING_SHOWN = 8;
|
|
@@ -13,15 +14,21 @@ export const HOME_DONE_SHOWN = 8;
|
|
|
13
14
|
const LEAD_LINE_LIMIT = 160;
|
|
14
15
|
/** Each group's heading, in the order Home shows them. */
|
|
15
16
|
export const HOME_ASK_LABEL = {
|
|
16
|
-
|
|
17
|
+
release: "Approve a release", approve: "Approve a plan", answer: "Answer a question", flow: "Flow decisions", wrong: "Something went wrong",
|
|
18
|
+
review: "Review results", setup: "Finish setting up", other: "Needs you",
|
|
17
19
|
};
|
|
18
|
-
/**
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** The groups in Home's one order. */
|
|
21
|
+
export const HOME_ASK_ORDER = ["release", "approve", "answer", "flow", "wrong", "review", "setup", "other"];
|
|
22
|
+
/** "19 tasks need you", "3 tasks and 2 other things need you", "2 things need you": the heading, said once. Tasks
|
|
23
|
+
* are the same total as the rail's count; `others` are the rows that aren't tasks (a held publication, a flow card, a
|
|
24
|
+
* setup step). */
|
|
25
|
+
export function homeSentence(needs, any, others = 0) {
|
|
26
|
+
const tasks = needs === 1 ? "1 task" : `${needs} tasks`;
|
|
27
|
+
if (others === 0)
|
|
28
|
+
return !any ? "No tasks yet" : needs === 0 ? "Nothing needs you" : `${tasks} ${needs === 1 ? "needs" : "need"} you`;
|
|
22
29
|
if (needs === 0)
|
|
23
|
-
return "
|
|
24
|
-
return
|
|
30
|
+
return others === 1 ? "1 thing needs you" : `${others} things need you`;
|
|
31
|
+
return `${tasks} and ${others} other ${others === 1 ? "thing" : "things"} need you`;
|
|
25
32
|
}
|
|
26
33
|
/** The lead's words as one plain line: no markdown marks, no line breaks, cut to about one line. */
|
|
27
34
|
export function leadLineOf(text) {
|
|
@@ -38,7 +45,15 @@ export function leadLineOf(text) {
|
|
|
38
45
|
export function startOfToday(now) {
|
|
39
46
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
40
47
|
}
|
|
41
|
-
function rowOf(store, item, now, multi, canAnswer, closedDoor, state) {
|
|
48
|
+
function rowOf(store, item, now, multi, canAnswer, closedDoor, state, release) {
|
|
49
|
+
// A release (approvers): read on its own sheet, never approved here. Its line is its version, or why the sheet can't
|
|
50
|
+
// take a yes now; its one link opens the sheet.
|
|
51
|
+
if (state === "needs" && item.askGroup === "release" && release !== null) {
|
|
52
|
+
const href = releaseSheetHref(item.activeTaskId);
|
|
53
|
+
return { id: item.rootId, title: item.title, href, state, ask: "release", reason: null,
|
|
54
|
+
project: multi || item.repo === null ? (item.repo === null ? "Unplaced" : projectName(item.repo)) : null,
|
|
55
|
+
age: relativeAge(item.updatedAt, now), line: release(item.activeTaskId), action: { label: "Review the release", href }, question: null };
|
|
56
|
+
}
|
|
42
57
|
const action = item.primaryAction;
|
|
43
58
|
const href = action?.code === "open-result" && action.target.runId != null
|
|
44
59
|
? `/review?result=${encodeURIComponent(action.target.taskId)}&run=${action.target.runId}` : browserWorkActionHref(item);
|
|
@@ -50,7 +65,8 @@ function rowOf(store, item, now, multi, canAnswer, closedDoor, state) {
|
|
|
50
65
|
id: decision.id, text: decision.question, recap: decision.recap,
|
|
51
66
|
options: decision.options.map(option => ({ id: option.id, label: option.label, consequence: option.consequence, recommended: option.id === decision.recommendation })),
|
|
52
67
|
} : null;
|
|
53
|
-
|
|
68
|
+
// Anyone else reads a release as the plan it is.
|
|
69
|
+
const ask = state === "needs" ? (item.askGroup === "release" ? "approve" : item.askGroup ?? "other") : null;
|
|
54
70
|
const said = item.status.detail !== item.status.label ? item.status.detail : null;
|
|
55
71
|
const line = state === "needs" && open ? decision.question : item.progress ?? said;
|
|
56
72
|
// Only a row where something went wrong says why, in one short line.
|
|
@@ -101,21 +117,40 @@ export function homeView(facts) {
|
|
|
101
117
|
const live = take(running.items, "running");
|
|
102
118
|
const queued = take(waiting.items, "queued");
|
|
103
119
|
const done = take(today.items, "completed");
|
|
104
|
-
const
|
|
105
|
-
const
|
|
120
|
+
const approver = facts.approver ?? facts.canAnswer;
|
|
121
|
+
const asks = facts.asks ?? NO_HOME_ASKS;
|
|
122
|
+
// A held publication, a flow card and a setup step are an approver's alone (home-asks.ts reads them only for one).
|
|
123
|
+
const held = approver ? asks.held : [], flows = approver ? asks.flows : [];
|
|
124
|
+
// The builder step waits behind a task row that already asks for a builder: that row says it.
|
|
125
|
+
const setup = approver ? asks.setup.filter(one => one.key !== "builder" || needs.builderWaits === 0) : [];
|
|
126
|
+
const extra = [...held, ...flows, ...setup];
|
|
127
|
+
const multi = project === null && new Set([...yours, ...live, ...queued, ...done].map(one => one.repo ?? "").concat(extra.flatMap(one => one.repo === null ? [] : [one.repo]))).size > 1;
|
|
128
|
+
const row = (item, state) => rowOf(store, item, now, multi, facts.canAnswer, facts.closedDoor, state, approver ? asks.release : null);
|
|
106
129
|
const rows = yours.map(one => row(one, "needs"));
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
130
|
+
const askRow = (one, ask) => ({ id: one.id, title: one.title, href: one.href, state: "needs", ask, reason: null,
|
|
131
|
+
project: multi && one.repo !== null ? projectName(one.repo) : null, age: one.at === null ? "" : relativeAge(one.at, now), line: one.line, action: one.action, question: null });
|
|
132
|
+
// Each group's count is exact over every row in view, not only those listed: the work index's total for a task ask
|
|
133
|
+
// (a release counted once, in its own group for an approver and as a plan for anyone else), and every other row.
|
|
134
|
+
const askTotals = {
|
|
135
|
+
release: (approver ? needs.asks.release : 0) + held.length, approve: needs.asks.approve + (approver ? 0 : needs.asks.release), answer: needs.asks.answer,
|
|
136
|
+
flow: flows.length, wrong: needs.asks.wrong, review: needs.asks.review, setup: setup.length, other: needs.asks.other,
|
|
137
|
+
};
|
|
138
|
+
const listedOf = {
|
|
139
|
+
release: [...rows.filter(one => one.ask === "release"), ...held.map(one => askRow(one, "release"))], approve: rows.filter(one => one.ask === "approve"),
|
|
140
|
+
answer: rows.filter(one => one.ask === "answer"), flow: flows.map(one => askRow(one, "flow")), wrong: rows.filter(one => one.ask === "wrong"),
|
|
141
|
+
review: rows.filter(one => one.ask === "review"), setup: setup.map(one => askRow(one, "setup")), other: rows.filter(one => one.ask === "other"),
|
|
142
|
+
};
|
|
143
|
+
const groups = HOME_ASK_ORDER.flatMap(key => {
|
|
144
|
+
const count = askTotals[key], listed = listedOf[key];
|
|
111
145
|
return count === 0 && listed.length === 0 ? [] : [{ key, label: HOME_ASK_LABEL[key], count: Math.max(count, listed.length), rows: listed }];
|
|
112
146
|
});
|
|
147
|
+
const others = extra.length;
|
|
113
148
|
const any = totals.all > 0 || totals.completed > 0 || (totals.archived ?? 0) > 0;
|
|
114
149
|
const lead = facts.chat ? facts.leadMessage ?? null : null;
|
|
115
150
|
const leadText = lead === null ? "" : leadLineOf(lead.text);
|
|
116
151
|
return {
|
|
117
152
|
kind: "home",
|
|
118
|
-
say: homeSentence(totals["needs-you"], any),
|
|
153
|
+
say: homeSentence(totals["needs-you"], any, others),
|
|
119
154
|
project,
|
|
120
155
|
scopes: facts.projects.length < 2 ? null : [
|
|
121
156
|
{ key: "", label: "All projects", href: "/", active: project === null },
|
|
@@ -127,6 +162,6 @@ export function homeView(facts) {
|
|
|
127
162
|
newTask: "/tasks/new",
|
|
128
163
|
ask: facts.chat ? { project, placeholder: "Message the lead" } : null,
|
|
129
164
|
lastLead: lead === null || leadText === "" ? null : { text: leadText, age: relativeAge(lead.at, now), href: "/chat" },
|
|
130
|
-
empty: any ? null : { title: "No tasks yet", text: "Describe a change in a sentence; an agent writes a plan for you to approve." },
|
|
165
|
+
empty: any || others > 0 ? null : { title: "No tasks yet", text: "Describe a change in a sentence; an agent writes a plan for you to approve." },
|
|
131
166
|
};
|
|
132
167
|
}
|
package/dist/server/pages.js
CHANGED
|
@@ -11,7 +11,8 @@ import { realpathSync } from "node:fs";
|
|
|
11
11
|
import {} from "node:http";
|
|
12
12
|
import { homedir, hostname } from "node:os";
|
|
13
13
|
import { basename, dirname, join, resolve } from "node:path";
|
|
14
|
-
import { buildersFor } from "../dispatch.js";
|
|
14
|
+
import { builderReadyFor, buildersFor, firstRunBuilderRepo } from "../dispatch.js";
|
|
15
|
+
import { withBuilderStep } from "../first-run.js";
|
|
15
16
|
import { listFolder, localOrigins } from "../folder-look.js";
|
|
16
17
|
import { Readable } from "node:stream";
|
|
17
18
|
import { pipeline } from "node:stream/promises";
|
|
@@ -70,6 +71,7 @@ import { consentDoorOf, decisionAnswerScript, taskBody } from "./render-tasks.js
|
|
|
70
71
|
import {} from "./session.js";
|
|
71
72
|
import { rereadBrowser } from "./live.js";
|
|
72
73
|
import { homeView } from "./home.js";
|
|
74
|
+
import { flowAsks, releaseAsks, setupAsks } from "./home-asks.js";
|
|
73
75
|
import { workIndexPage } from "../work-index.js";
|
|
74
76
|
export function createPagesHandlers(runtime) {
|
|
75
77
|
const { identities, clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession, firstRunStepsNow, chatEnablement, leadPrincipal, leadConversationRows } = runtime;
|
|
@@ -204,8 +206,9 @@ export function createPagesHandlers(runtime) {
|
|
|
204
206
|
return door.open ? null : door;
|
|
205
207
|
};
|
|
206
208
|
const chat = chrome.chat === true;
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
+
const access = workAccess(), approver = who.role === "approver";
|
|
210
|
+
const view = { ...homeView({ store, now, access, evidenceRoot, project, projects, chat, canAnswer: approver, approver, closedDoor,
|
|
211
|
+
leadMessage: chat ? leadLastMessage(who, now) : null, asks: homeAsksFor(who, access, now, project) }),
|
|
209
212
|
// Until the first result: the welcome's three steps stay one link away.
|
|
210
213
|
setup: firstRunStepsNow(now) !== null ? "/welcome" : null };
|
|
211
214
|
return sendScreen(response, 200, screen("Home", html `<div data-kit-page><h1>Home</h1><p>${view.say}</p><p><a href="/tasks">Tasks</a></p></div>`, { chrome, workspace: { view } }));
|
|
@@ -1226,5 +1229,14 @@ export function createPagesHandlers(runtime) {
|
|
|
1226
1229
|
const said = leadConversationRows(who, principal, null, now, null).messages.filter(one => one.role === "assistant" && one.pending !== true).at(-1);
|
|
1227
1230
|
return said === undefined ? null : { text: said.text, at: said.createdAt };
|
|
1228
1231
|
}
|
|
1232
|
+
/** What else waits on this person at Home (home-asks.ts): releases and held publications in the work index's own
|
|
1233
|
+
* access, flow cards in the projects this person may pick (the Flows list's admission, each flow's project re-checked
|
|
1234
|
+
* against the account), and the first-run steps with the builder step as Chat reads them. Approvers only. */
|
|
1235
|
+
function homeAsksFor(who, access, now, project) {
|
|
1236
|
+
const approver = who.role === "approver";
|
|
1237
|
+
const steps = withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, runtime.managedRepos()), now));
|
|
1238
|
+
return { ...releaseAsks(store, now, access, project, approver), flows: flowAsks(store, consoleProjects(), { name: who.name, approver }, project),
|
|
1239
|
+
setup: setupAsks(steps, approver) };
|
|
1240
|
+
}
|
|
1229
1241
|
return { registrations, projectsScreen };
|
|
1230
1242
|
}
|
package/dist/work-index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type WorkIndexItem = {
|
|
|
27
27
|
ask: Ask | null;
|
|
28
28
|
/** The row's chip under its group: the specific ask (needs-you.ts askChipOf), or none. */
|
|
29
29
|
chip: AskChip | null;
|
|
30
|
-
/** What a Needs you row asks, in Home's
|
|
30
|
+
/** What a Needs you row asks, in Home's six groups (ASK_GROUP_SQL); null for every other row. */
|
|
31
31
|
askGroup: WorkAskGroup | null;
|
|
32
32
|
primaryAction: WorkAction | null;
|
|
33
33
|
completion: {
|
|
@@ -65,18 +65,23 @@ export type WorkIndexOptions = {
|
|
|
65
65
|
/** How the list is grouped: the Needs you rows by their ask, then building, then the rest. */
|
|
66
66
|
export type WorkIndexGroup = Ask | 'building' | 'rest';
|
|
67
67
|
export type WorkIndexGroupCounts = Record<WorkIndexGroup, number>;
|
|
68
|
-
/** What a Needs you row asks, as Home groups it (docs/plans/live-pages-and-home.md B1.2): a
|
|
69
|
-
* to answer, something that went wrong, a result to review, or anything else that needs the
|
|
70
|
-
|
|
68
|
+
/** What a Needs you row asks, as Home groups it (docs/plans/live-pages-and-home.md B1.2): a release to approve, a plan
|
|
69
|
+
* to approve, a question to answer, something that went wrong, a result to review, or anything else that needs the
|
|
70
|
+
* person. A release is a plan to approve whose active task the plane's sealed state names a release (release-approval.ts
|
|
71
|
+
* releaseTaskOf, docs/plans/home-other-asks.md v2.1); a failed release is still something that went wrong and a
|
|
72
|
+
* release's result still a result to review. */
|
|
73
|
+
export type WorkAskGroup = 'release' | 'approve' | 'answer' | 'wrong' | 'review' | 'other';
|
|
71
74
|
export declare const WORK_ASK_GROUPS: readonly WorkAskGroup[];
|
|
72
75
|
/** `asks`: the exact Needs you totals per ask group, over every row in view (never only the page). `completedSince`:
|
|
73
|
-
* how many the Completed view lists from `options.completedSince` on (0 without it).
|
|
76
|
+
* how many the Completed view lists from `options.completedSince` on (0 without it). `builderWaits`: how many Needs you
|
|
77
|
+
* rows wait for a builder to connect or start (no worker registered or none online). */
|
|
74
78
|
export type WorkIndexPage = {
|
|
75
79
|
items: WorkIndexItem[];
|
|
76
80
|
totals: WorkIndexCounts;
|
|
77
81
|
groups: WorkIndexGroupCounts;
|
|
78
82
|
asks: Record<WorkAskGroup, number>;
|
|
79
83
|
completedSince: number;
|
|
84
|
+
builderWaits: number;
|
|
80
85
|
projects: WorkProjectCounts[];
|
|
81
86
|
nextCursor: string | null;
|
|
82
87
|
limit: number;
|
package/dist/work-index.js
CHANGED
|
@@ -21,13 +21,14 @@ import { withFollowUps } from './result-follow-ups.js';
|
|
|
21
21
|
import { LANDED_ACTOR, landedReasonOf } from './result-completion.js';
|
|
22
22
|
import { ASKS, CODE_READING, STORAGE_HOLD_PREFIX, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, isWaitCode, processNeedOf, resultHoldUpSentence } from './needs-you.js';
|
|
23
23
|
import { retryWhenWords } from './dispatch.js';
|
|
24
|
+
import { releaseTaskOf } from './release-approval.js';
|
|
24
25
|
import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
|
|
25
|
-
export const WORK_ASK_GROUPS = ['approve', 'answer', 'wrong', 'review', 'other'];
|
|
26
|
+
export const WORK_ASK_GROUPS = ['release', 'approve', 'answer', 'wrong', 'review', 'other'];
|
|
26
27
|
export const WORK_INDEX_PAGE_LIMIT = 40;
|
|
27
28
|
export const WORK_INDEX_MAX_LIMIT = 100;
|
|
28
29
|
const EMPTY = { all: 0, 'needs-you': 0, ready: 0, running: 0, queued: 0, completed: 0, archived: 0 };
|
|
29
30
|
const NO_GROUPS = { decide: 0, review: 0, unblock: 0, building: 0, rest: 0 };
|
|
30
|
-
const NO_ASKS = { approve: 0, answer: 0, wrong: 0, review: 0, other: 0 };
|
|
31
|
+
const NO_ASKS = { release: 0, approve: 0, answer: 0, wrong: 0, review: 0, other: 0 };
|
|
31
32
|
const FAMILY_PROBLEM = 'Task history is unavailable or incomplete. This task is shown separately.';
|
|
32
33
|
const VIEWS = ['all', 'needs-you', 'ready', 'running', 'queued', 'completed', 'archived'];
|
|
33
34
|
/** The family's current version is put away (v145): it waits on nobody and only the Archived view lists it. */
|
|
@@ -75,7 +76,7 @@ const ASK_RANK = `CASE WHEN code IN (${codesOf('decide')}) THEN 0 WHEN code IN (
|
|
|
75
76
|
/** Home's ask group of a row, by its code alone, so a row's group and its group's count are one rule: a plan waiting
|
|
76
77
|
* for its yes, a question, a build that failed or stopped unexpectedly, a saved result to review (ASK_RANK's Review),
|
|
77
78
|
* and everything else that waits on a person. Read on Needs you rows only. */
|
|
78
|
-
const ASK_GROUP_SQL = `CASE WHEN code='needs-approval' THEN 'approve' WHEN code IN ('waiting-decision','decision-queue') THEN 'answer'
|
|
79
|
+
const ASK_GROUP_SQL = `CASE WHEN code='needs-approval' AND c.id IN (SELECT value FROM json_each($releaseTasks)) THEN 'release' WHEN code='needs-approval' THEN 'approve' WHEN code IN ('waiting-decision','decision-queue') THEN 'answer'
|
|
79
80
|
WHEN code IN ('failed','waiting-incident','vanished-run') THEN 'wrong' WHEN code IN (${codesOf('review')}) AND NOT custody_unresolved THEN 'review' ELSE 'other' END`;
|
|
80
81
|
/** Where a row sits in the list: Decide, Review, Unblock, then building, then the rest by recency. */
|
|
81
82
|
const ORDER = `(CASE WHEN rank=0 THEN ask_rank WHEN rank=1 THEN 3 ELSE 4 END)`;
|
|
@@ -348,9 +349,17 @@ function custodyReadings(store) {
|
|
|
348
349
|
}
|
|
349
350
|
return { readings, workspace: new Set(noted) };
|
|
350
351
|
}
|
|
351
|
-
|
|
352
|
+
/** The list can't read a release's sealed state in SQL: each queued task (the only state a plan waiting for a yes is in)
|
|
353
|
+
* whose scope carries a release form, kept only where release-approval.ts releaseTaskOf names it a release, the one
|
|
354
|
+
* rule the release sheet reads (never a title or a task id). */
|
|
355
|
+
function releaseTaskIds(store) {
|
|
356
|
+
const sealed = store.handle.prepare(`SELECT s.task_id FROM task_scope s JOIN task t ON t.id=s.task_id
|
|
357
|
+
WHERE t.state='queued' AND (s.release_terms_json IS NOT NULL OR s.release_check_json IS NOT NULL)`).all().map(row => String(row['task_id']));
|
|
358
|
+
return JSON.stringify(sealed.filter(taskId => releaseTaskOf(store, taskId) !== null));
|
|
359
|
+
}
|
|
360
|
+
function parameters(store, now, access, options, custody) {
|
|
352
361
|
const ids = (keep) => JSON.stringify([...custody.readings].filter(([ref, fact]) => keep(ref, fact)).map(([ref]) => ref));
|
|
353
|
-
return { $settledCustody: ids((_, fact) => fact === null), $waitingCustody: ids((_, fact) => fact !== null && 'wait' in (processNeedOf(fact) ?? {})),
|
|
362
|
+
return { $releaseTasks: releaseTaskIds(store), $settledCustody: ids((_, fact) => fact === null), $waitingCustody: ids((_, fact) => fact !== null && 'wait' in (processNeedOf(fact) ?? {})),
|
|
354
363
|
$workspaceCustody: ids((ref, fact) => fact !== null && custody.workspace.has(ref)),
|
|
355
364
|
$now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
|
|
356
365
|
$all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []),
|
|
@@ -373,7 +382,8 @@ const GROUP_TOTALS = `COALESCE(SUM(rank=0 AND ask_rank=0 AND archived=0),0) deci
|
|
|
373
382
|
+ `COALESCE(SUM(rank=1 AND archived=0),0) building_count,COALESCE(SUM(rank>1 AND archived=0),0) rest_count`;
|
|
374
383
|
/** The exact Needs you totals per ask group, and what was completed since the page's `completedSince`: page reads only. */
|
|
375
384
|
const ASK_TOTALS = WORK_ASK_GROUPS.map(group => `COALESCE(SUM(needs AND ask_group='${group}'),0) ask_${group}_count`).join(',')
|
|
376
|
-
+ `,COALESCE(SUM(code='complete' AND archived=0 AND $completedSince IS NOT NULL AND sort_at>=$completedSince),0) completed_since_count
|
|
385
|
+
+ `,COALESCE(SUM(code='complete' AND archived=0 AND $completedSince IS NOT NULL AND sort_at>=$completedSince),0) completed_since_count`
|
|
386
|
+
+ `,COALESCE(SUM(needs AND code IN ('no-worker-registered','no-worker-online')),0) builder_wait_count`;
|
|
377
387
|
function askCounts(row) {
|
|
378
388
|
return Object.fromEntries(WORK_ASK_GROUPS.map(group => [group, n(row, `ask_${group}_count`)]));
|
|
379
389
|
}
|
|
@@ -438,7 +448,7 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
|
|
|
438
448
|
)
|
|
439
449
|
SELECT 0 record_kind,totals.*,(SELECT json_group_array(json_object('repo',repo,'all_count',all_count,'needs_count',needs_count,'ready_count',ready_count,
|
|
440
450
|
'running_count',running_count,'queued_count',queued_count,'completed_count',completed_count,'archived_count',archived_count,'queued',queued,'done_recently',done_recently)) FROM project_totals) row_json FROM totals
|
|
441
|
-
UNION ALL SELECT 1,0,0,0,0,0,0,0,0,0,0,0,0,${WORK_ASK_GROUPS.map(() => '0,').join('')}0,json_object('archived',archived,'archived_by',(SELECT by FROM task_archive WHERE task_ref=page.ref_id),
|
|
451
|
+
UNION ALL SELECT 1,0,0,0,0,0,0,0,0,0,0,0,0,${WORK_ASK_GROUPS.map(() => '0,').join('')}0,0,json_object('archived',archived,'archived_by',(SELECT by FROM task_archive WHERE task_ref=page.ref_id),
|
|
442
452
|
'archived_kind',(SELECT by_kind FROM task_archive WHERE task_ref=page.ref_id),'archived_reason',(SELECT reason FROM task_archive WHERE task_ref=page.ref_id),
|
|
443
453
|
'archived_at',(SELECT at FROM task_archive WHERE task_ref=page.ref_id),'root_ref',root_ref,'root_id',root_id,'id',id,'root_title',root_title,'repo',repo,
|
|
444
454
|
'state',state,'created_at',created_at,'family_updated',family_updated,'version_count',version_count,'earlier_active',earlier_active,
|
|
@@ -458,7 +468,7 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
|
|
|
458
468
|
'agent_ask_run',(SELECT run FROM agent_question WHERE id=page.agent_ask),'agent_ask_text',(SELECT substr(question,1,300) FROM agent_question WHERE id=page.agent_ask),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
|
|
459
469
|
'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),'machine_check',COALESCE(machine_check,0),
|
|
460
470
|
'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
|
|
461
|
-
.all({ ...parameters(now, access, options, custody), $taskId: taskId, $roots: options.roots === undefined ? null : JSON.stringify(options.roots), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $completedSince: options.completedSince ?? null, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
|
|
471
|
+
.all({ ...parameters(store, now, access, options, custody), $taskId: taskId, $roots: options.roots === undefined ? null : JSON.stringify(options.roots), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $completedSince: options.completedSince ?? null, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
|
|
462
472
|
const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
|
|
463
473
|
const page = selected.slice(0, limit), last = page.at(-1);
|
|
464
474
|
const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 2, scope,
|
|
@@ -477,13 +487,13 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
|
|
|
477
487
|
const readiness = (row, checks) => resultReadinessFacts(store, now, options.root, row, checks, memo);
|
|
478
488
|
return { items: page.map(row => itemOf(row, access.principal, probe, leadName, readiness, options.root !== undefined, now)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
|
|
479
489
|
groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), asks: rows[0] === undefined ? { ...NO_ASKS } : askCounts(rows[0]),
|
|
480
|
-
completedSince: rows[0] === undefined ? 0 : n(rows[0], 'completed_since_count'), nextCursor, limit, view };
|
|
490
|
+
completedSince: rows[0] === undefined ? 0 : n(rows[0], 'completed_since_count'), builderWaits: rows[0] === undefined ? 0 : n(rows[0], 'builder_wait_count'), nextCursor, limit, view };
|
|
481
491
|
}
|
|
482
492
|
export function workCountsByProject(store, now, access) {
|
|
483
493
|
const projection = registerValidators(store);
|
|
484
494
|
return store.handle.prepare(`${projection} SELECT repo,${TOTALS},SUM(state='queued' AND family_running=0 AND archived=0) queued,
|
|
485
495
|
SUM(state='done' AND family_running=0 AND archived=0 AND updated_at>=$recent) done_recently FROM ranked GROUP BY repo ORDER BY repo`)
|
|
486
|
-
.all({ ...parameters(now, access, {}, custodyReadings(store)), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
|
|
496
|
+
.all({ ...parameters(store, now, access, {}, custodyReadings(store)), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
|
|
487
497
|
.map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
|
|
488
498
|
}
|
|
489
499
|
export function workStateRows(store, now, access) {
|
|
@@ -495,7 +505,7 @@ export function workStateRows(store, now, access) {
|
|
|
495
505
|
COALESCE((SELECT held_at FROM hold WHERE id=hold_id),''),COALESCE((SELECT requested_at FROM run_stop WHERE run=stop_run),'')),''),sort_at) ask_at,
|
|
496
506
|
COALESCE(live_run,(SELECT run FROM decision WHERE id=question_id),(SELECT run FROM agent_question WHERE id=agent_ask)) ask_run,
|
|
497
507
|
(SELECT json_group_array(m.ref_id) FROM members m WHERE m.root_ref=ranked.root_ref) members FROM ranked WHERE archived=0`)
|
|
498
|
-
.all(parameters(now, access, {}, custodyReadings(store)))
|
|
508
|
+
.all(parameters(store, now, access, {}, custodyReadings(store)))
|
|
499
509
|
.map(row => ({ repo: s(row, 'repo'), rootId: String(row['root_id']), code: String(row['code']), needs: n(row, 'needs') === 1, personNeed: n(row, 'person_need') === 1,
|
|
500
510
|
leadClaim: n(row, 'lead_claim') === 1, liveRun: row['live_run'] == null ? null : n(row, 'live_run'), unfinished: row['unfinished'] == null ? null : n(row, 'unfinished'),
|
|
501
511
|
resultRun: row['result_id'] == null ? null : n(row, 'result_id'), waits: Number(row['rank']) === 2, version: String(row['version']), at: String(row['ask_at'] ?? ''), askRun: row['ask_run'] == null ? null : n(row, 'ask_run'), members: JSON.parse(String(row['members'] ?? '[]')).map(Number) }));
|
|
@@ -530,7 +540,7 @@ export function workOutcomeRows(store, now, access, project, since, limit) {
|
|
|
530
540
|
(SELECT status FROM run_check WHERE run=f.result_id) check_status,
|
|
531
541
|
COALESCE(a.runs,0) runs,COALESCE(a.api_runs,0) api_runs,a.api_microusd,COALESCE(a.subscription_runs,0) subscription_runs,a.subscription_usd
|
|
532
542
|
FROM selected f LEFT JOIN attempts a ON a.root_ref=f.root_ref ORDER BY f.sort_at DESC,f.root_ref DESC`)
|
|
533
|
-
.all({ ...parameters(now, access, { project }, custodyReadings(store)), $since: since, $outcomeLimit: limit + 1 });
|
|
543
|
+
.all({ ...parameters(store, now, access, { project }, custodyReadings(store)), $since: since, $outcomeLimit: limit + 1 });
|
|
534
544
|
}
|
|
535
545
|
/** Families whose headline is Ready for review, newest first: the Tasks
|
|
536
546
|
* list's own rows, so a completed family (root or any revision), a failed
|
|
@@ -783,7 +793,7 @@ export function workTidyRows(store, now, access) {
|
|
|
783
793
|
(family_running OR live_lease IS NOT NULL OR unfinished IS NOT NULL OR custody_unresolved OR stop_run IS NOT NULL AND stop_settled IS NULL OR state='running') live,
|
|
784
794
|
(SELECT json_group_array(m.ref_id) FROM members m WHERE m.root_ref=ranked.root_ref) members,
|
|
785
795
|
(SELECT json_group_array(m.id) FROM members m WHERE m.root_ref=ranked.root_ref) member_ids FROM ranked`)
|
|
786
|
-
.all(parameters(now, access, {}, custodyReadings(store)))
|
|
796
|
+
.all(parameters(store, now, access, {}, custodyReadings(store)))
|
|
787
797
|
.map(row => ({ rootId: String(row['root_id']), currentId: String(row['id']), title: String(row['root_title']), repo: s(row, 'repo'),
|
|
788
798
|
state: String(row['state']), code: String(row['code']), live: n(row, 'live') === 1, archived: n(row, 'archived') === 1, broken: n(row, 'broken') === 1,
|
|
789
799
|
updatedAt: String(row['family_updated'] ?? ''), members: JSON.parse(String(row['members'] ?? '[]')).map(Number),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.103",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|