portable-agent-layer 0.110.0 → 0.112.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 +1 -0
- package/assets/schema/pal-settings.schema.json +8 -0
- package/assets/skills/research-entity/SKILL.md +6 -0
- package/assets/templates/AGENTS.md.template +1 -0
- package/assets/templates/pal-settings.json +2 -0
- package/package.json +1 -1
- package/src/cli/knowledge-research.ts +6 -1
- package/src/cli/tree.ts +2 -0
- package/src/cli/work.ts +344 -0
- package/src/hooks/StopOrchestrator.ts +10 -0
- package/src/hooks/handlers/entity-extraction.ts +2 -0
- package/src/hooks/handlers/inject-retrieval.ts +2 -0
- package/src/hooks/lib/claim-check.ts +20 -12
- package/src/hooks/lib/claim-log.ts +1 -1
- package/src/hooks/lib/claude-md.ts +11 -2
- package/src/hooks/lib/company-register-router.ts +24 -0
- package/src/hooks/lib/company-register.ts +25 -0
- package/src/hooks/lib/context.ts +3 -0
- package/src/hooks/lib/entity-extraction.ts +1 -0
- package/src/hooks/lib/entity-research.ts +88 -3
- package/src/hooks/lib/hungarian-registry.ts +194 -0
- package/src/hooks/lib/work-tree-context.ts +25 -0
- package/src/hooks/lib/work-tree-gate.ts +67 -0
- package/src/hooks/lib/work-tree-git.ts +98 -0
- package/src/hooks/lib/work-tree-render.ts +157 -0
- package/src/hooks/lib/work-tree.ts +307 -0
- package/src/tools/lib/command.ts +8 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the work tree reads as text: the CLI's views, the block a new or
|
|
3
|
+
* compacted session starts with, and the one line every prompt carries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { activePath, nodesWithStatus, type ScopeTree, type WorkNode } from "./work-tree";
|
|
7
|
+
|
|
8
|
+
const SESSION_BUDGET = 2_000;
|
|
9
|
+
const RECENT_DECISIONS = 3;
|
|
10
|
+
const LISTED_PARKED = 5;
|
|
11
|
+
const PROMPT_LINE_MAX = 300;
|
|
12
|
+
|
|
13
|
+
const RECORD_HINT =
|
|
14
|
+
"Record as you go: pal cli work stopped|decide|attach|follow-up|open|done (pal cli work --help)";
|
|
15
|
+
|
|
16
|
+
export function suffix(
|
|
17
|
+
lead: string,
|
|
18
|
+
value: string | null | undefined,
|
|
19
|
+
tail = ""
|
|
20
|
+
): string {
|
|
21
|
+
return value ? `${lead}${value}${tail}` : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clip(text: string, max: number): string {
|
|
25
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function label(node: WorkNode): string {
|
|
29
|
+
return `${node.title} [${node.id}]`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stoppedClause(node: WorkNode): string {
|
|
33
|
+
return node.stoppedAt
|
|
34
|
+
? ` — stopped at: ${node.stoppedAt}`
|
|
35
|
+
: " — no stopped-at recorded";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function due(followUp: { due?: string }): string {
|
|
39
|
+
return followUp.due ? ` (due ${followUp.due.slice(0, 10)})` : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderNodeDetail(tree: ScopeTree, node: WorkNode): string {
|
|
43
|
+
const path = activePath({ ...tree, active: node.id })
|
|
44
|
+
.map((n) => n.title)
|
|
45
|
+
.join(" › ");
|
|
46
|
+
const lines = [
|
|
47
|
+
`${label(node)} — ${node.status}`,
|
|
48
|
+
`Path: ${path}`,
|
|
49
|
+
`Stopped at: ${node.stoppedAt ?? "(not recorded)"}`,
|
|
50
|
+
];
|
|
51
|
+
if (node.waitingOn) lines.push(`Waiting on: ${node.waitingOn}`);
|
|
52
|
+
if (node.summary) lines.push(`Summary: ${node.summary}`);
|
|
53
|
+
if (node.links.isc !== undefined) lines.push(`ISC: ${node.links.isc}`);
|
|
54
|
+
if (node.links.commitment) lines.push(`Commitment: ${node.links.commitment}`);
|
|
55
|
+
const section = (title: string, rows: string[]) => {
|
|
56
|
+
if (rows.length) lines.push(`${title}:`, ...rows.map((r) => ` - ${r}`));
|
|
57
|
+
};
|
|
58
|
+
section(
|
|
59
|
+
"Decisions",
|
|
60
|
+
node.decisions.map(
|
|
61
|
+
(d) => `${d.ts.slice(0, 10)} ${d.text}${suffix(" (why: ", d.why, ")")}`
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
section(
|
|
65
|
+
"Artifacts",
|
|
66
|
+
node.artifacts.map((a) => `${a.kind} ${a.ref}${suffix(" — ", a.label)}`)
|
|
67
|
+
);
|
|
68
|
+
section(
|
|
69
|
+
"Follow-ups",
|
|
70
|
+
node.followUps.map((f) => `${f.text}${due(f)}`)
|
|
71
|
+
);
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function childrenOf(tree: ScopeTree, parent: string | null): WorkNode[] {
|
|
76
|
+
return [...tree.nodes.values()]
|
|
77
|
+
.filter(
|
|
78
|
+
(n) => n.parent === parent || (parent === null && !tree.nodes.has(n.parent ?? ""))
|
|
79
|
+
)
|
|
80
|
+
.sort((a, b) => a.opened.localeCompare(b.opened));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const MARK: Record<WorkNode["status"], string> = {
|
|
84
|
+
active: "▶",
|
|
85
|
+
parked: "‖",
|
|
86
|
+
waiting: "?",
|
|
87
|
+
done: "✓",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function renderTreeList(tree: ScopeTree, includeDone: boolean): string {
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
const walk = (node: WorkNode, depth: number) => {
|
|
93
|
+
if (node.status === "done" && !includeDone) return;
|
|
94
|
+
const stop = node.status === "done" ? "" : stoppedClause(node);
|
|
95
|
+
lines.push(`${" ".repeat(depth)}${MARK[node.status]} ${label(node)}${stop}`);
|
|
96
|
+
for (const child of childrenOf(tree, node.id)) walk(child, depth + 1);
|
|
97
|
+
};
|
|
98
|
+
for (const root of childrenOf(tree, null)) walk(root, 0);
|
|
99
|
+
return lines.length ? lines.join("\n") : `No work recorded for ${tree.scope} yet.`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function activeSection(tree: ScopeTree): string[] {
|
|
103
|
+
const path = activePath(tree);
|
|
104
|
+
const active = path.at(-1);
|
|
105
|
+
if (!active) return ["Active: none — enter a parked node or open one."];
|
|
106
|
+
const lines = [`Active: ${label(active)}${stoppedClause(active)}`];
|
|
107
|
+
if (path.length > 1) lines.push(` Inside: ${path.map((n) => n.title).join(" › ")}`);
|
|
108
|
+
for (const d of active.decisions.slice(-RECENT_DECISIONS))
|
|
109
|
+
lines.push(` Decided ${d.ts.slice(0, 10)}: ${d.text}`);
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function listSection(title: string, rows: string[]): string[] {
|
|
114
|
+
return rows.length ? [title, ...rows.map((r) => `- ${r}`)] : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What a new or compacted session needs to pick the work back up without digging. */
|
|
118
|
+
export function renderSessionContext(tree: ScopeTree): string {
|
|
119
|
+
if (tree.nodes.size === 0) return "";
|
|
120
|
+
const nodes = [...tree.nodes.values()];
|
|
121
|
+
const open = nodes.filter((n) => n.status !== "done");
|
|
122
|
+
const lines = [
|
|
123
|
+
`## Work tree — ${tree.scope}`,
|
|
124
|
+
...activeSection(tree),
|
|
125
|
+
...listSection(
|
|
126
|
+
"Waiting on the user:",
|
|
127
|
+
nodesWithStatus(tree, "waiting").map((n) => `${label(n)}: ${n.waitingOn}`)
|
|
128
|
+
),
|
|
129
|
+
...listSection(
|
|
130
|
+
"Parked:",
|
|
131
|
+
nodesWithStatus(tree, "parked")
|
|
132
|
+
.slice(0, LISTED_PARKED)
|
|
133
|
+
.map((n) => `${label(n)}${stoppedClause(n)}`)
|
|
134
|
+
),
|
|
135
|
+
...listSection(
|
|
136
|
+
"Follow-ups:",
|
|
137
|
+
open.flatMap((n) => n.followUps.map((f) => `${f.text}${due(f)} — on ${n.title}`))
|
|
138
|
+
),
|
|
139
|
+
RECORD_HINT,
|
|
140
|
+
];
|
|
141
|
+
return clip(lines.join("\n"), SESSION_BUDGET);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const NEW_TOPIC_HINT =
|
|
145
|
+
'New topic: if this is separate work, park the active node with its stopped-at (pal cli work park --stopped "…") and open one for this (pal cli work open "…").';
|
|
146
|
+
|
|
147
|
+
/** The line every prompt carries, so the active node survives anything that drops context. */
|
|
148
|
+
export function renderPromptLine(tree: ScopeTree, newTopic: boolean): string | null {
|
|
149
|
+
if (tree.nodes.size === 0) return null;
|
|
150
|
+
const active = tree.active ? tree.nodes.get(tree.active) : undefined;
|
|
151
|
+
const parked = nodesWithStatus(tree, "parked").length;
|
|
152
|
+
const head = active
|
|
153
|
+
? `Work: ${label(active)}${stoppedClause(active)}`
|
|
154
|
+
: "Work: no active node";
|
|
155
|
+
const line = clip(`${head} · ${parked} parked`, PROMPT_LINE_MAX);
|
|
156
|
+
return newTopic ? `${line}\n${NEW_TOPIC_HINT}` : line;
|
|
157
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The work tree: what the user is working on, what is parked and where each
|
|
3
|
+
* piece of work stopped, kept as an append-only event log so a compaction, a
|
|
4
|
+
* new session or a detour never loses it. The tree is derived on every read;
|
|
5
|
+
* nothing but the events is stored.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { ensureDir, paths } from "./paths";
|
|
11
|
+
import { readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
12
|
+
|
|
13
|
+
export type NodeStatus = "active" | "parked" | "waiting" | "done";
|
|
14
|
+
|
|
15
|
+
export type ArtifactKind =
|
|
16
|
+
| "commit"
|
|
17
|
+
| "branch"
|
|
18
|
+
| "pr"
|
|
19
|
+
| "artifact"
|
|
20
|
+
| "file"
|
|
21
|
+
| "run"
|
|
22
|
+
| "link";
|
|
23
|
+
|
|
24
|
+
export const ARTIFACT_KINDS: readonly ArtifactKind[] = [
|
|
25
|
+
"commit",
|
|
26
|
+
"branch",
|
|
27
|
+
"pr",
|
|
28
|
+
"artifact",
|
|
29
|
+
"file",
|
|
30
|
+
"run",
|
|
31
|
+
"link",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
interface NodeLinks {
|
|
35
|
+
isc?: number;
|
|
36
|
+
commitment?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface EventBase {
|
|
40
|
+
id: string;
|
|
41
|
+
ts: string;
|
|
42
|
+
scope: string;
|
|
43
|
+
node: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type WorkEvent = EventBase &
|
|
47
|
+
(
|
|
48
|
+
| { type: "open"; title: string; parent: string | null; links?: NodeLinks }
|
|
49
|
+
| { type: "enter" }
|
|
50
|
+
| { type: "park"; stoppedAt?: string }
|
|
51
|
+
| { type: "wait"; on: string }
|
|
52
|
+
| { type: "done"; summary?: string }
|
|
53
|
+
| { type: "stopped"; text: string }
|
|
54
|
+
| { type: "decide"; text: string; why?: string }
|
|
55
|
+
| { type: "attach"; kind: ArtifactKind; ref: string; label?: string }
|
|
56
|
+
| { type: "follow-up"; text: string; due?: string }
|
|
57
|
+
| { type: "git-cursor"; branch: string; head: string }
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
type Payload<T extends WorkEvent["type"]> = Omit<
|
|
61
|
+
Extract<WorkEvent, { type: T }>,
|
|
62
|
+
keyof EventBase | "type"
|
|
63
|
+
>;
|
|
64
|
+
|
|
65
|
+
interface Decision {
|
|
66
|
+
ts: string;
|
|
67
|
+
text: string;
|
|
68
|
+
why?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Artifact {
|
|
72
|
+
ts: string;
|
|
73
|
+
kind: ArtifactKind;
|
|
74
|
+
ref: string;
|
|
75
|
+
label?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface FollowUp {
|
|
79
|
+
ts: string;
|
|
80
|
+
text: string;
|
|
81
|
+
due?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WorkNode {
|
|
85
|
+
id: string;
|
|
86
|
+
title: string;
|
|
87
|
+
parent: string | null;
|
|
88
|
+
status: NodeStatus;
|
|
89
|
+
stoppedAt: string | null;
|
|
90
|
+
waitingOn: string | null;
|
|
91
|
+
summary: string | null;
|
|
92
|
+
decisions: Decision[];
|
|
93
|
+
artifacts: Artifact[];
|
|
94
|
+
followUps: FollowUp[];
|
|
95
|
+
links: NodeLinks;
|
|
96
|
+
opened: string;
|
|
97
|
+
updated: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface GitCursor {
|
|
101
|
+
branch: string;
|
|
102
|
+
head: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ScopeTree {
|
|
106
|
+
scope: string;
|
|
107
|
+
active: string | null;
|
|
108
|
+
nodes: Map<string, WorkNode>;
|
|
109
|
+
gitCursor: GitCursor | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function workTreeFile(): string {
|
|
113
|
+
return resolve(ensureDir(resolve(paths.memory(), "work-tree")), "events.jsonl");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A registered project is its own scope wherever in it the agent stands; anything else is its folder. */
|
|
117
|
+
export function scopeOf(cwd: string): string {
|
|
118
|
+
return resolveProjectFromCwd(cwd, readAllProjects())?.name ?? resolve(cwd);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function newNodeId(
|
|
122
|
+
now: number = Date.now(),
|
|
123
|
+
random: () => number = Math.random
|
|
124
|
+
): string {
|
|
125
|
+
return now.toString(36).slice(-5) + random().toString(36).slice(2, 5);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseLine(line: string): WorkEvent[] {
|
|
129
|
+
try {
|
|
130
|
+
return [JSON.parse(line) as WorkEvent];
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Ordered by time, so the union an import merges in replays the same as one machine's log. */
|
|
137
|
+
export function readEvents(file: string = workTreeFile()): WorkEvent[] {
|
|
138
|
+
if (!existsSync(file)) return [];
|
|
139
|
+
return readFileSync(file, "utf-8")
|
|
140
|
+
.split("\n")
|
|
141
|
+
.filter((line) => line.trim())
|
|
142
|
+
.flatMap(parseLine)
|
|
143
|
+
.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function appendEvent<T extends WorkEvent["type"]>(
|
|
147
|
+
scope: string,
|
|
148
|
+
node: string,
|
|
149
|
+
type: T,
|
|
150
|
+
payload: Payload<T>,
|
|
151
|
+
file: string = workTreeFile(),
|
|
152
|
+
now: Date = new Date()
|
|
153
|
+
): WorkEvent {
|
|
154
|
+
const event = {
|
|
155
|
+
id: newNodeId(now.getTime()),
|
|
156
|
+
ts: now.toISOString(),
|
|
157
|
+
scope,
|
|
158
|
+
node,
|
|
159
|
+
type,
|
|
160
|
+
...payload,
|
|
161
|
+
} as WorkEvent;
|
|
162
|
+
appendFileSync(file, `${JSON.stringify(event)}\n`, "utf-8");
|
|
163
|
+
return event;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function emptyNode(
|
|
167
|
+
id: string,
|
|
168
|
+
title: string,
|
|
169
|
+
parent: string | null,
|
|
170
|
+
ts: string
|
|
171
|
+
): WorkNode {
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
title,
|
|
175
|
+
parent,
|
|
176
|
+
status: "active",
|
|
177
|
+
stoppedAt: null,
|
|
178
|
+
waitingOn: null,
|
|
179
|
+
summary: null,
|
|
180
|
+
decisions: [],
|
|
181
|
+
artifacts: [],
|
|
182
|
+
followUps: [],
|
|
183
|
+
links: {},
|
|
184
|
+
opened: ts,
|
|
185
|
+
updated: ts,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parkActive(tree: ScopeTree): void {
|
|
190
|
+
const current = tree.active ? tree.nodes.get(tree.active) : undefined;
|
|
191
|
+
if (current?.status === "active") current.status = "parked";
|
|
192
|
+
tree.active = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function activate(tree: ScopeTree, node: WorkNode): void {
|
|
196
|
+
if (tree.active !== node.id) parkActive(tree);
|
|
197
|
+
node.status = "active";
|
|
198
|
+
node.waitingOn = null;
|
|
199
|
+
tree.active = node.id;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Finishing a detour hands the work back to the nearest unfinished ancestor. */
|
|
203
|
+
function returnToParent(tree: ScopeTree, node: WorkNode): void {
|
|
204
|
+
let parent = node.parent ? tree.nodes.get(node.parent) : undefined;
|
|
205
|
+
while (parent && parent.status === "done")
|
|
206
|
+
parent = parent.parent ? tree.nodes.get(parent.parent) : undefined;
|
|
207
|
+
if (parent) activate(tree, parent);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function applyToNode(tree: ScopeTree, node: WorkNode, event: WorkEvent): void {
|
|
211
|
+
switch (event.type) {
|
|
212
|
+
case "enter":
|
|
213
|
+
activate(tree, node);
|
|
214
|
+
break;
|
|
215
|
+
case "park":
|
|
216
|
+
if (event.stoppedAt) node.stoppedAt = event.stoppedAt;
|
|
217
|
+
node.status = "parked";
|
|
218
|
+
if (tree.active === node.id) tree.active = null;
|
|
219
|
+
break;
|
|
220
|
+
case "wait":
|
|
221
|
+
node.status = "waiting";
|
|
222
|
+
node.waitingOn = event.on;
|
|
223
|
+
if (tree.active === node.id) tree.active = null;
|
|
224
|
+
break;
|
|
225
|
+
case "done": {
|
|
226
|
+
const wasActive = tree.active === node.id;
|
|
227
|
+
node.status = "done";
|
|
228
|
+
node.summary = event.summary ?? node.summary;
|
|
229
|
+
if (wasActive) {
|
|
230
|
+
tree.active = null;
|
|
231
|
+
returnToParent(tree, node);
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "stopped":
|
|
236
|
+
node.stoppedAt = event.text;
|
|
237
|
+
break;
|
|
238
|
+
case "decide":
|
|
239
|
+
node.decisions.push({ ts: event.ts, text: event.text, why: event.why });
|
|
240
|
+
break;
|
|
241
|
+
case "attach":
|
|
242
|
+
node.artifacts.push({
|
|
243
|
+
ts: event.ts,
|
|
244
|
+
kind: event.kind,
|
|
245
|
+
ref: event.ref,
|
|
246
|
+
label: event.label,
|
|
247
|
+
});
|
|
248
|
+
break;
|
|
249
|
+
case "follow-up":
|
|
250
|
+
node.followUps.push({ ts: event.ts, text: event.text, due: event.due });
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
node.updated = event.ts;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function apply(tree: ScopeTree, event: WorkEvent): void {
|
|
257
|
+
if (event.type === "git-cursor") {
|
|
258
|
+
tree.gitCursor = { branch: event.branch, head: event.head };
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (event.type === "open") {
|
|
262
|
+
const node = emptyNode(event.node, event.title, event.parent, event.ts);
|
|
263
|
+
node.links = event.links ?? {};
|
|
264
|
+
tree.nodes.set(node.id, node);
|
|
265
|
+
activate(tree, node);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const node = tree.nodes.get(event.node);
|
|
269
|
+
if (node) applyToNode(tree, node, event);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function buildTree(scope: string, events: WorkEvent[]): ScopeTree {
|
|
273
|
+
const tree: ScopeTree = { scope, active: null, nodes: new Map(), gitCursor: null };
|
|
274
|
+
for (const event of events) if (event.scope === scope) apply(tree, event);
|
|
275
|
+
return tree;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function readTree(scope: string, file: string = workTreeFile()): ScopeTree {
|
|
279
|
+
return buildTree(scope, readEvents(file));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** From the root down to the active node: the detours the user is inside of. */
|
|
283
|
+
export function activePath(tree: ScopeTree): WorkNode[] {
|
|
284
|
+
const path: WorkNode[] = [];
|
|
285
|
+
let node = tree.active ? tree.nodes.get(tree.active) : undefined;
|
|
286
|
+
while (node) {
|
|
287
|
+
path.unshift(node);
|
|
288
|
+
node = node.parent ? tree.nodes.get(node.parent) : undefined;
|
|
289
|
+
}
|
|
290
|
+
return path;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function byRecent(a: WorkNode, b: WorkNode): number {
|
|
294
|
+
return b.updated.localeCompare(a.updated);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function nodesWithStatus(tree: ScopeTree, status: NodeStatus): WorkNode[] {
|
|
298
|
+
return [...tree.nodes.values()].filter((n) => n.status === status).sort(byRecent);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** An id, or any unambiguous prefix of one. */
|
|
302
|
+
export function findNode(tree: ScopeTree, idOrPrefix: string): WorkNode | null {
|
|
303
|
+
const exact = tree.nodes.get(idOrPrefix);
|
|
304
|
+
if (exact) return exact;
|
|
305
|
+
const matches = [...tree.nodes.values()].filter((n) => n.id.startsWith(idOrPrefix));
|
|
306
|
+
return matches.length === 1 ? matches[0] : null;
|
|
307
|
+
}
|
package/src/tools/lib/command.ts
CHANGED
|
@@ -78,7 +78,15 @@ export async function runCommand(
|
|
|
78
78
|
: runLeaf(command, args, path);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function startsWithFallbackOption(command: Group, args: string[]): boolean {
|
|
82
|
+
const first = args[0];
|
|
83
|
+
if (!command.fallback || first === undefined || !first.startsWith("-")) return false;
|
|
84
|
+
return !HELP_FLAGS.has(first) && !command.aliases?.[first];
|
|
85
|
+
}
|
|
86
|
+
|
|
81
87
|
async function runGroup(command: Group, args: string[], path: string[]): Promise<number> {
|
|
88
|
+
if (command.fallback && startsWithFallbackOption(command, args))
|
|
89
|
+
return runGroup(command, [command.fallback, ...args], path);
|
|
82
90
|
const [first, ...rest] = args;
|
|
83
91
|
const name = first === undefined ? command.fallback : first;
|
|
84
92
|
if (name === undefined || name === "help" || HELP_FLAGS.has(name)) {
|