projectkeeper 0.0.0-stage → 1.0.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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a step read (Spec §1.11: `Read in full` is what a job actually read; CKC-13 AC-8), worked out from the call and its
|
|
3
|
+
* result when the step ends and recorded on the step (`JobStep.reads`), so the coverage counts what reached the agent
|
|
4
|
+
* however it was read, and tells a whole file from some of its lines.
|
|
5
|
+
*
|
|
6
|
+
* - The file tool (`read`): the lines it showed — from its offset to where its own notice says it stopped (pi cuts a file
|
|
7
|
+
* at 2000 lines or 50 KB and says where to go on), or to the end.
|
|
8
|
+
* - A shell command: tokenised the way the command boundary tokenises it (command.ts), each file resolved against the
|
|
9
|
+
* directory the command runs in the way the boundary resolves paths (paths.ts), and followed down its pipeline: `cat`
|
|
10
|
+
* and its like show a file whole; `head`, `tail`, `sed -n 'a,bp'` and `awk 'NR>=a && NR<=b'` show some of its lines;
|
|
11
|
+
* a `| head -50` after them cuts what reached it. Searching (`grep`, `rg` …) is not reading, nor are listing and
|
|
12
|
+
* counting (`ls`, `find`, `wc`), nor is a program's output (`python -c`, `node -e`, `xargs`) a read of what it opened.
|
|
13
|
+
* `git show <commit>:<path>` reads a version from the history, `git show <commit>` a commit. Output the shell cut to
|
|
14
|
+
* its tail showed only a part of what was read. A command the boundary refused read nothing; one that ran and exited
|
|
15
|
+
* non-zero still showed its output.
|
|
16
|
+
* - The ledger's document tools: a version of a document and its lines, the current one or one from the history; a
|
|
17
|
+
* commit, read whole.
|
|
18
|
+
*
|
|
19
|
+
* Only files that exist are kept, so a word that merely looks like a path (a pattern, a branch, a count) never counts. A
|
|
20
|
+
* file's length in lines is taken when a range of it was read, so ranges read in several calls can be seen to cover it.
|
|
21
|
+
*/
|
|
22
|
+
import { globSync, readFileSync, statSync } from 'node:fs';
|
|
23
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
24
|
+
import type { StepRead } from '../../model/types.ts';
|
|
25
|
+
import { commandName, extractHeredocs, tokenize, type Token } from './command.ts';
|
|
26
|
+
import { toAbsolute } from './paths.ts';
|
|
27
|
+
|
|
28
|
+
/** Lines `[from, to]`, 1-based and inclusive; `to` null: to the end. */
|
|
29
|
+
type Range = readonly [number, number | null];
|
|
30
|
+
/** How much of a file (or of a stream) reaches the agent: all of it, some lines by number, its last lines, or a part. */
|
|
31
|
+
type Extent =
|
|
32
|
+
| { readonly kind: 'whole' }
|
|
33
|
+
| { readonly kind: 'lines'; readonly ranges: readonly Range[] }
|
|
34
|
+
| { readonly kind: 'last'; readonly count: number }
|
|
35
|
+
| { readonly kind: 'part' };
|
|
36
|
+
const WHOLE: Extent = { kind: 'whole' };
|
|
37
|
+
const PART: Extent = { kind: 'part' };
|
|
38
|
+
const lines = (ranges: readonly Range[]): Extent | null => (ranges.length ? { kind: 'lines', ranges } : null);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a command does to the lines coming in on its standard input: shows them all (`cat`, `sort`), none of them
|
|
42
|
+
* (searching, counting, a program: `grep`, `wc`, `xargs`), some not by number (`cut`, a pattern `sed`), some by number
|
|
43
|
+
* (`head -50`, `sed -n '5,9p'`), the last ones (`tail -20`), or from a line on (`tail -n +5`).
|
|
44
|
+
*/
|
|
45
|
+
type Filter =
|
|
46
|
+
| { readonly kind: 'keep' } | { readonly kind: 'none' } | { readonly kind: 'part' }
|
|
47
|
+
| { readonly kind: 'lines'; readonly ranges: readonly Range[] }
|
|
48
|
+
| { readonly kind: 'last'; readonly count: number }
|
|
49
|
+
| { readonly kind: 'from'; readonly line: number };
|
|
50
|
+
const KEEP: Filter = { kind: 'keep' };
|
|
51
|
+
const NONE: Filter = { kind: 'none' };
|
|
52
|
+
const SOME: Filter = { kind: 'part' };
|
|
53
|
+
|
|
54
|
+
/** A stream that is `e` of a file, after `f`: what of the file still reaches the output (null: nothing). */
|
|
55
|
+
function through(e: Extent | null, f: Filter): Extent | null {
|
|
56
|
+
if (!e) return null;
|
|
57
|
+
switch (f.kind) {
|
|
58
|
+
case 'keep': return e;
|
|
59
|
+
case 'none': return null;
|
|
60
|
+
case 'part': return PART;
|
|
61
|
+
case 'lines': return narrow(e, f.ranges);
|
|
62
|
+
case 'last':
|
|
63
|
+
if (e.kind === 'whole') return { kind: 'last', count: f.count };
|
|
64
|
+
if (e.kind === 'last') return { kind: 'last', count: Math.min(e.count, f.count) };
|
|
65
|
+
if (e.kind === 'lines' && e.ranges.length === 1 && e.ranges[0]![1] !== null) {
|
|
66
|
+
const [a, b] = e.ranges[0]! as readonly [number, number];
|
|
67
|
+
return lines([[Math.max(a, b - f.count + 1), b]]);
|
|
68
|
+
}
|
|
69
|
+
return PART;
|
|
70
|
+
case 'from':
|
|
71
|
+
if (e.kind === 'whole') return lines([[f.line, null]]);
|
|
72
|
+
if (e.kind === 'lines' && e.ranges.length === 1) {
|
|
73
|
+
const [a, b] = e.ranges[0]!;
|
|
74
|
+
const start = a + f.line - 1;
|
|
75
|
+
return b !== null && start > b ? null : lines([[start, b]]);
|
|
76
|
+
}
|
|
77
|
+
if (e.kind === 'last') return e.count - f.line + 1 > 0 ? { kind: 'last', count: e.count - f.line + 1 } : null;
|
|
78
|
+
return PART;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Lines `ranges` of a stream that is `e` of a file (the stream's line n is the file's line n when `e` is whole). */
|
|
83
|
+
function narrow(e: Extent, ranges: readonly Range[]): Extent | null {
|
|
84
|
+
if (e.kind === 'whole') return lines(ranges);
|
|
85
|
+
if (e.kind === 'lines' && e.ranges.length === 1) {
|
|
86
|
+
const [a, b] = e.ranges[0]!;
|
|
87
|
+
const out: Range[] = [];
|
|
88
|
+
for (const [x, y] of ranges) {
|
|
89
|
+
const from = a + x - 1;
|
|
90
|
+
const to = y === null ? b : b === null ? a + y - 1 : Math.min(b, a + y - 1);
|
|
91
|
+
if (to === null || from <= to) out.push([from, to]);
|
|
92
|
+
}
|
|
93
|
+
return lines(out);
|
|
94
|
+
}
|
|
95
|
+
// The first N lines of the last C: all of them when N reaches C.
|
|
96
|
+
if (e.kind === 'last' && ranges.length === 1 && ranges[0]![0] === 1 && (ranges[0]![1] === null || ranges[0]![1] >= e.count)) return e;
|
|
97
|
+
return PART;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One read a shell command makes: a file (or a version of it from the history, `rev`) and how much of it it shows. */
|
|
101
|
+
interface Intent { readonly path: string; readonly extent: Extent; readonly rev?: string }
|
|
102
|
+
|
|
103
|
+
/** What one simple command reads itself, what it does to its standard input, and whether it reads that input at all. */
|
|
104
|
+
interface Stage {
|
|
105
|
+
readonly reads: readonly Intent[];
|
|
106
|
+
readonly commits: readonly string[];
|
|
107
|
+
readonly filter: Filter;
|
|
108
|
+
readonly readsStdin: boolean;
|
|
109
|
+
/** Its standard output goes to a file (or nowhere), not down the pipe or to the agent. */
|
|
110
|
+
readonly outputAway: boolean;
|
|
111
|
+
}
|
|
112
|
+
const nothing = (outputAway: boolean, readsStdin = false): Stage => ({ reads: [], commits: [], filter: NONE, readsStdin, outputAway });
|
|
113
|
+
|
|
114
|
+
const isFile = (path: string): boolean => { try { return statSync(path).isFile(); } catch { return false; } };
|
|
115
|
+
|
|
116
|
+
/** Files a word names: the file itself, or what a glob in it matches. A word that names no file names nothing. */
|
|
117
|
+
function filesOf(word: Token, cwd: string | null): string[] {
|
|
118
|
+
if (word.dynamic || word.substitution || !word.text) return [];
|
|
119
|
+
if (cwd === null && !/^([A-Za-z]:[\\/]|\/|~|\\\\)/.test(word.text)) return []; // relative, and where it is relative to is lost
|
|
120
|
+
const base = cwd ?? process.cwd();
|
|
121
|
+
const abs = toAbsolute(word.text, base);
|
|
122
|
+
if (isFile(abs)) return [abs];
|
|
123
|
+
if (!/[*?[]/.test(word.text)) return [];
|
|
124
|
+
try { return globSync(abs.replace(/\\/g, '/')).slice(0, 500).map((p) => (isAbsolute(p) ? p : resolve(base, p))).filter(isFile); } catch { return []; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Where the repository holding `dir` has its root (the nearest directory up with a `.git`), for `git show <rev>:<path>`. */
|
|
128
|
+
function repoRootOf(dir: string): string | null {
|
|
129
|
+
let d = dir;
|
|
130
|
+
for (let i = 0; i < 64; i++) {
|
|
131
|
+
try { statSync(join(d, '.git')); return d; } catch { /* go up */ }
|
|
132
|
+
const up = dirname(d);
|
|
133
|
+
if (up === d) return null;
|
|
134
|
+
d = up;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A command's operands: not options (unless after `--`), and not the values of the options listed as taking one. */
|
|
140
|
+
function operandsOf(args: readonly Token[], valued: readonly string[] = []): Token[] {
|
|
141
|
+
const out: Token[] = [];
|
|
142
|
+
let done = false;
|
|
143
|
+
for (let i = 0; i < args.length; i++) {
|
|
144
|
+
const w = args[i]!;
|
|
145
|
+
if (done || w.text === '-' || !w.text.startsWith('-')) { out.push(w); continue; }
|
|
146
|
+
if (w.text === '--') { done = true; continue; }
|
|
147
|
+
if (valued.includes(w.text)) i += 1;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `head` and `tail`: how many lines, from which line, or bytes (then not by line). */
|
|
153
|
+
function headTail(name: 'head' | 'tail', args: readonly Token[]): { filter: Filter; files: Token[] } {
|
|
154
|
+
let count = 10;
|
|
155
|
+
let from: number | null = null;
|
|
156
|
+
let some = false;
|
|
157
|
+
const files: Token[] = [];
|
|
158
|
+
for (let i = 0; i < args.length; i++) {
|
|
159
|
+
const a = args[i]!.text;
|
|
160
|
+
let v: string;
|
|
161
|
+
if (a === '--') { files.push(...args.slice(i + 1)); break; }
|
|
162
|
+
if (a === '-n' || a === '--lines') v = args[++i]?.text ?? '';
|
|
163
|
+
else if (/^-n.+/.test(a)) v = a.slice(2);
|
|
164
|
+
else if (a.startsWith('--lines=')) v = a.slice(8);
|
|
165
|
+
else if (a === '-c' || a === '--bytes') { some = true; i += 1; continue; }
|
|
166
|
+
else if (/^-c.+/.test(a) || a.startsWith('--bytes=')) { some = true; continue; }
|
|
167
|
+
else if (/^-\d+$/.test(a)) v = a.slice(1);
|
|
168
|
+
else if (name === 'tail' && /^\+\d+$/.test(a)) v = a;
|
|
169
|
+
else if (a.startsWith('-') && a !== '-') continue; // -q, -v, -f, -F …
|
|
170
|
+
else { files.push(args[i]!); continue; }
|
|
171
|
+
if (name === 'tail' && v.startsWith('+')) from = Math.max(1, Number(v.slice(1)) || 1);
|
|
172
|
+
else if (/^\d+$/.test(v)) count = Number(v);
|
|
173
|
+
else some = true; // head -n -5 (all but the last five), a unit
|
|
174
|
+
}
|
|
175
|
+
const filter: Filter = some ? SOME
|
|
176
|
+
: name === 'head' ? (count > 0 ? { kind: 'lines', ranges: [[1, count]] } : NONE)
|
|
177
|
+
: from !== null ? { kind: 'from', line: from }
|
|
178
|
+
: count > 0 ? { kind: 'last', count } : NONE;
|
|
179
|
+
return { filter, files };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The lines a sed script prints: with `-n`, only `p` at line numbers (`5p`, `5,9p`, `5,$p`, `5,+4p`) give lines by
|
|
184
|
+
* number, and a `q` only stops it; without `-n` every line is printed unless a `q` stops it early, and a script that only
|
|
185
|
+
* substitutes moves no line. Anything else (a pattern address, `d`, `a`, `y` …) prints some lines, not by number. A `$p`
|
|
186
|
+
* alone is the last line.
|
|
187
|
+
*/
|
|
188
|
+
function sedFilter(script: string, quiet: boolean): Filter {
|
|
189
|
+
const cmds = script.split(/[;\n]/).map((s) => s.trim()).filter(Boolean);
|
|
190
|
+
const ranges: Range[] = [];
|
|
191
|
+
let quitAt: number | null = null;
|
|
192
|
+
let lastLine = false;
|
|
193
|
+
for (const c of cmds) {
|
|
194
|
+
let m: RegExpExecArray | null;
|
|
195
|
+
if ((m = /^(\d+)\s*(?:,\s*(\d+|\$|\+\d+))?\s*p$/.exec(c))) {
|
|
196
|
+
const a = Number(m[1]);
|
|
197
|
+
const b = m[2] === undefined ? a : m[2] === '$' ? null : m[2].startsWith('+') ? a + Number(m[2].slice(1)) : Number(m[2]);
|
|
198
|
+
ranges.push([Math.max(1, a), b]);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (/^\$\s*p$/.test(c)) { lastLine = true; continue; }
|
|
202
|
+
if ((m = /^(\d+)\s*q$/.exec(c))) { quitAt = Number(m[1]); continue; }
|
|
203
|
+
if (!quiet && /^s(.).*\1.*\1[gip0-9]*$/.test(c)) continue; // substitutes in place: every line still shown
|
|
204
|
+
return SOME;
|
|
205
|
+
}
|
|
206
|
+
if (!quiet) return quitAt !== null ? { kind: 'lines', ranges: [[1, quitAt]] } : KEEP;
|
|
207
|
+
if (lastLine) return ranges.length ? SOME : { kind: 'last', count: 1 };
|
|
208
|
+
const kept = ranges.map(([a, b]): Range => [a, quitAt === null ? b : Math.min(b ?? quitAt, quitAt)]).filter(([a, b]) => b === null || a <= b);
|
|
209
|
+
return kept.length ? { kind: 'lines', ranges: kept } : NONE;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `sed`: its script (`-n`, `-e`, the first operand) and its files; null for `sed -i`, which writes and shows nothing. */
|
|
213
|
+
function sedOf(args: readonly Token[]): { filter: Filter; files: Token[] } | null {
|
|
214
|
+
let quiet = false;
|
|
215
|
+
let fromFile = false;
|
|
216
|
+
const scripts: string[] = [];
|
|
217
|
+
const operands: Token[] = [];
|
|
218
|
+
for (let i = 0; i < args.length; i++) {
|
|
219
|
+
const a = args[i]!.text;
|
|
220
|
+
if (a === '--') { operands.push(...args.slice(i + 1)); break; }
|
|
221
|
+
if (a === '-n' || a === '--quiet' || a === '--silent') { quiet = true; continue; }
|
|
222
|
+
if (a === '-e' || a === '--expression') { scripts.push(args[++i]?.text ?? ''); continue; }
|
|
223
|
+
if (a.startsWith('--expression=')) { scripts.push(a.slice(13)); continue; }
|
|
224
|
+
if (a === '-f' || a === '--file') { fromFile = true; i += 1; continue; }
|
|
225
|
+
if (a.startsWith('--file=')) { fromFile = true; continue; }
|
|
226
|
+
if (a.startsWith('--in-place')) return null;
|
|
227
|
+
if (/^-[A-Za-z]+/.test(a)) { // a cluster: -n, -ne, -nE, -En, -i.bak
|
|
228
|
+
const letters = /^-([A-Za-z]+)/.exec(a)![1]!;
|
|
229
|
+
if (letters.includes('i')) return null;
|
|
230
|
+
if (letters.includes('n')) quiet = true;
|
|
231
|
+
if (letters.endsWith('e')) scripts.push(args[++i]?.text ?? '');
|
|
232
|
+
else if (letters.endsWith('f')) { fromFile = true; i += 1; }
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (a.startsWith('--')) continue;
|
|
236
|
+
operands.push(args[i]!);
|
|
237
|
+
}
|
|
238
|
+
if (!scripts.length && !fromFile) scripts.push(operands.shift()?.text ?? '');
|
|
239
|
+
return { filter: fromFile ? SOME : sedFilter(scripts.join('\n'), quiet), files: operands };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The lines an awk program prints of its input: `NR>=a && NR<=b` (either order, `>`/`<` too), `NR==n`, `NR>=a`, `NR<=b`,
|
|
244
|
+
* with or without `{print}`; every line for `1` or `{print}`; none for a program with only an END block (a count, a
|
|
245
|
+
* total); some, not by number, for anything else.
|
|
246
|
+
*/
|
|
247
|
+
function awkFilter(program: string): Filter {
|
|
248
|
+
const s = program.replace(/\s+/g, ' ').trim();
|
|
249
|
+
if (/^(1|\{ ?print( \$0)? ?;? ?\})$/.test(s)) return KEEP;
|
|
250
|
+
if (/^END ?\{/.test(s)) return NONE;
|
|
251
|
+
const body = s.replace(/ ?\{ ?print( \$0)? ?;? ?\}$/, '');
|
|
252
|
+
const at = (op: string, n: string, low: boolean) => Number(n) + (op === '>' && low ? 1 : op === '<' && !low ? -1 : 0);
|
|
253
|
+
let m: RegExpExecArray | null;
|
|
254
|
+
if ((m = /^NR ?(>=|>) ?(\d+) ?&& ?NR ?(<=|<) ?(\d+)$/.exec(body))) return { kind: 'lines', ranges: [[at(m[1]!, m[2]!, true), at(m[3]!, m[4]!, false)]] };
|
|
255
|
+
if ((m = /^NR ?(<=|<) ?(\d+) ?&& ?NR ?(>=|>) ?(\d+)$/.exec(body))) return { kind: 'lines', ranges: [[at(m[3]!, m[4]!, true), at(m[1]!, m[2]!, false)]] };
|
|
256
|
+
if ((m = /^NR ?== ?(\d+)$/.exec(body))) return { kind: 'lines', ranges: [[Number(m[1]), Number(m[1])]] };
|
|
257
|
+
if ((m = /^NR ?(>=|>) ?(\d+)$/.exec(body))) return { kind: 'from', line: at(m[1]!, m[2]!, true) };
|
|
258
|
+
if ((m = /^NR ?(<=|<) ?(\d+)$/.exec(body))) return { kind: 'lines', ranges: [[1, at(m[1]!, m[2]!, false)]] };
|
|
259
|
+
return SOME;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function awkOf(args: readonly Token[]): { filter: Filter; files: Token[] } {
|
|
263
|
+
let program: string | null = null;
|
|
264
|
+
let fromFile = false;
|
|
265
|
+
const operands: Token[] = [];
|
|
266
|
+
for (let i = 0; i < args.length; i++) {
|
|
267
|
+
const a = args[i]!.text;
|
|
268
|
+
if (a === '--') { operands.push(...args.slice(i + 1)); break; }
|
|
269
|
+
if (a === '-F' || a === '-v') { i += 1; continue; }
|
|
270
|
+
if (a === '-f') { fromFile = true; i += 1; continue; }
|
|
271
|
+
if (a.startsWith('-') && a !== '-') continue;
|
|
272
|
+
if (program === null && !fromFile) { program = a; continue; }
|
|
273
|
+
if (/^[A-Za-z_]\w*=/.test(a)) continue; // var=value sets a variable
|
|
274
|
+
operands.push(args[i]!);
|
|
275
|
+
}
|
|
276
|
+
return { filter: fromFile || program === null ? SOME : awkFilter(program), files: operands };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Commands that show every line of the files they are given, or of their input (reordered or reformatted at most). */
|
|
280
|
+
const SHOWS_ALL = new Set(['cat', 'tac', 'nl', 'more', 'less', 'most', 'bat', 'batcat', 'pr', 'fold', 'fmt', 'expand', 'unexpand', 'column', 'sort', 'uniq', 'rev', 'iconv']);
|
|
281
|
+
/** Commands that show some of what they read, not by line number. */
|
|
282
|
+
const SHOWS_SOME = new Set(['cut', 'strings', 'diff', 'comm', 'colordiff']);
|
|
283
|
+
/** The options of those commands that take a value, so the value is not taken for a file. */
|
|
284
|
+
const BAT_VALUED = ['-l', '--language', '-H', '--highlight-line', '--style', '--theme', '-m', '--map-syntax', '--tabs', '--wrap', '--terminal-width'];
|
|
285
|
+
const VALUED: Readonly<Record<string, readonly string[]>> = {
|
|
286
|
+
nl: ['-b', '-n', '-s', '-v', '-i', '-w', '-l', '-d', '-f', '-h'],
|
|
287
|
+
sort: ['-k', '-t', '-o', '-S', '-T', '--key', '--field-separator', '--output', '--buffer-size', '--temporary-directory'],
|
|
288
|
+
fold: ['-w', '--width'], fmt: ['-w', '--width', '-g', '-p'], column: ['-s', '-c', '-o', '-N', '-W', '-H', '-R', '-E', '-O'],
|
|
289
|
+
uniq: ['-f', '-s', '-w'], pr: ['-l', '-w', '-h', '-o', '-s', '-e', '-i', '-n', '-N'], iconv: ['-f', '-t', '--from-code', '--to-code'],
|
|
290
|
+
expand: ['-t'], unexpand: ['-t'], more: ['-n'], less: ['-x', '-y', '-z', '-b', '-h', '-j', '-k', '-o', '-O', '-p', '-P', '-t', '-T'],
|
|
291
|
+
bat: BAT_VALUED, batcat: BAT_VALUED,
|
|
292
|
+
cut: ['-d', '-f', '-c', '-b', '--delimiter', '--fields', '--characters', '--bytes', '--output-delimiter'],
|
|
293
|
+
diff: ['-U', '-C', '-W', '--label', '-I', '-x', '-X', '-L'], strings: ['-n', '-t', '-e'],
|
|
294
|
+
};
|
|
295
|
+
/** Searching, counting, checking, encoding, programs: what they read does not reach the agent as text to read. */
|
|
296
|
+
const SHOWS_NONE_READS_INPUT = new Set(['grep', 'egrep', 'fgrep', 'zgrep', 'rg', 'ag', 'ack', 'findstr', 'wc', 'md5sum', 'sha1sum', 'sha256sum', 'sha512sum', 'cksum', 'cmp', 'base64', 'od', 'xxd', 'hexdump', 'xargs', 'python', 'python2', 'python3', 'node', 'nodejs', 'deno', 'perl', 'ruby']);
|
|
297
|
+
/** Words that lead into a command without being one. */
|
|
298
|
+
const LEADING = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', 'time', '{', 'command', 'builtin', 'exec', 'nohup', 'stdbuf']);
|
|
299
|
+
/** A command node that starts with one of these reads nothing itself (a loop header, the end of a block). */
|
|
300
|
+
const HEADERS = new Set(['for', 'case', 'select', 'function', 'done', 'fi', 'esac', '}']);
|
|
301
|
+
const BASH_FAMILY = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
|
|
302
|
+
const OUTPUT_REDIRECTS = new Set(['>', '>>', '1>', '1>>', '&>', '&>>']);
|
|
303
|
+
const INPUT_REDIRECTS = new Set(['<', '0<']);
|
|
304
|
+
const TEXT_INPUT = new Set(['<<', '<<-', '<<<']);
|
|
305
|
+
|
|
306
|
+
/** A PowerShell command's reads: `Get-Content` and its aliases, and what `Select-Object` keeps of a pipe. */
|
|
307
|
+
function powerShellStage(name: string, args: readonly Token[], cwd: string | null): Stage | null {
|
|
308
|
+
const n = name.toLowerCase();
|
|
309
|
+
const value = (flags: readonly string[]): string | null => {
|
|
310
|
+
const i = args.findIndex((a) => flags.includes(a.text.toLowerCase()));
|
|
311
|
+
return i >= 0 ? args[i + 1]?.text ?? null : null;
|
|
312
|
+
};
|
|
313
|
+
const number = (v: string | null) => (v !== null && /^\d+$/.test(v) ? Number(v) : null);
|
|
314
|
+
if (n === 'get-content' || n === 'gc' || n === 'cat' || n === 'type') {
|
|
315
|
+
const head = number(value(['-totalcount', '-head', '-first']));
|
|
316
|
+
const tail = number(value(['-tail', '-last']));
|
|
317
|
+
const extent: Extent = head !== null ? { kind: 'lines', ranges: [[1, head]] } : tail !== null ? { kind: 'last', count: tail } : WHOLE;
|
|
318
|
+
const valued = new Set(['-totalcount', '-head', '-first', '-tail', '-last', '-encoding', '-delimiter', '-readcount', '-stream']);
|
|
319
|
+
const files: Token[] = [];
|
|
320
|
+
for (let i = 0; i < args.length; i++) {
|
|
321
|
+
const t = args[i]!.text.toLowerCase();
|
|
322
|
+
if (t === '-path' || t === '-literalpath') { if (args[i + 1]) files.push(args[i + 1]!); i += 1; continue; }
|
|
323
|
+
if (valued.has(t)) { i += 1; continue; }
|
|
324
|
+
if (!t.startsWith('-')) files.push(args[i]!);
|
|
325
|
+
}
|
|
326
|
+
return { reads: files.flatMap((f) => filesOf(f, cwd)).map((path) => ({ path, extent })), commits: [], filter: KEEP, readsStdin: files.length === 0, outputAway: false };
|
|
327
|
+
}
|
|
328
|
+
if (n === 'select-object' || n === 'select') {
|
|
329
|
+
const first = number(value(['-first']));
|
|
330
|
+
const last = number(value(['-last']));
|
|
331
|
+
const skip = number(value(['-skip']));
|
|
332
|
+
const filter: Filter = first !== null ? { kind: 'lines', ranges: [[1, first]] } : last !== null ? { kind: 'last', count: last } : skip !== null ? { kind: 'from', line: skip + 1 } : SOME;
|
|
333
|
+
return { reads: [], commits: [], filter, readsStdin: true, outputAway: false };
|
|
334
|
+
}
|
|
335
|
+
if (['out-string', 'out-host', 'format-table', 'ft', 'format-list', 'fl', 'oh', 'out-default'].includes(n)) return { reads: [], commits: [], filter: KEEP, readsStdin: true, outputAway: false };
|
|
336
|
+
if (['select-string', 'sls', 'measure-object', 'measure', 'out-null', 'set-content', 'out-file', 'add-content'].includes(n)) return nothing(false, true);
|
|
337
|
+
if (['where-object', 'where', '?', 'foreach-object', 'foreach', '%'].includes(n)) return { reads: [], commits: [], filter: SOME, readsStdin: true, outputAway: false };
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `git show <rev>:<path>`, `git cat-file -p <rev>:<path>`, `git show <commit>`, `git blame [-L a,b] <file>`. */
|
|
342
|
+
function gitStage(args: readonly Token[], cwd: string | null): Stage {
|
|
343
|
+
let dir = cwd;
|
|
344
|
+
let i = 0;
|
|
345
|
+
for (; i < args.length; i++) {
|
|
346
|
+
const a = args[i]!.text;
|
|
347
|
+
if (a === '-C') { const v = args[i + 1]; dir = v && !v.dynamic && !v.substitution && dir !== null ? toAbsolute(v.text, dir) : null; i += 1; continue; }
|
|
348
|
+
if (/^-C./.test(a)) { dir = dir !== null ? toAbsolute(a.slice(2), dir) : null; continue; }
|
|
349
|
+
if (a === '-c' || a === '--git-dir' || a === '--work-tree' || a === '--namespace') { i += 1; continue; }
|
|
350
|
+
if (a.startsWith('-')) continue;
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
const sub = args[i]?.text ?? '';
|
|
354
|
+
const rest = args.slice(i + 1);
|
|
355
|
+
const reads: Intent[] = [];
|
|
356
|
+
const commits: string[] = [];
|
|
357
|
+
if (sub === 'show' || sub === 'cat-file') {
|
|
358
|
+
for (const w of rest) {
|
|
359
|
+
if (w.text === '--') break;
|
|
360
|
+
if (w.text.startsWith('-') || w.dynamic || w.substitution) continue;
|
|
361
|
+
const m = /^([^:]+):(.+)$/.exec(w.text);
|
|
362
|
+
if (m && !/^[A-Za-z]:[\\/]/.test(w.text)) {
|
|
363
|
+
if (dir === null) continue;
|
|
364
|
+
const rel = m[2]!;
|
|
365
|
+
const root = rel.startsWith('./') || rel.startsWith('../') ? dir : repoRootOf(dir);
|
|
366
|
+
if (root) reads.push({ path: resolve(root, rel), rev: m[1]!, extent: WHOLE });
|
|
367
|
+
} else if (sub === 'show' && /^[0-9a-f]{7,40}$/i.test(w.text)) commits.push(w.text.toLowerCase());
|
|
368
|
+
}
|
|
369
|
+
} else if (sub === 'blame') {
|
|
370
|
+
let range: Range | null = null;
|
|
371
|
+
const files: Token[] = [];
|
|
372
|
+
for (let j = 0; j < rest.length; j++) {
|
|
373
|
+
const t = rest[j]!.text;
|
|
374
|
+
const spec = t === '-L' ? rest[++j]?.text ?? '' : /^-L./.test(t) ? t.slice(2) : null;
|
|
375
|
+
if (spec !== null) {
|
|
376
|
+
const m = /^(\d+),(\d+|\+\d+)?$/.exec(spec);
|
|
377
|
+
range = m ? [Number(m[1]), m[2] === undefined ? null : m[2].startsWith('+') ? Number(m[1]) + Number(m[2].slice(1)) : Number(m[2])] : null;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (!t.startsWith('-')) files.push(rest[j]!);
|
|
381
|
+
}
|
|
382
|
+
const extent: Extent = range ? { kind: 'lines', ranges: [range] } : WHOLE;
|
|
383
|
+
for (const f of files) for (const path of filesOf(f, dir)) reads.push({ path, extent });
|
|
384
|
+
}
|
|
385
|
+
return { reads, commits, filter: NONE, readsStdin: false, outputAway: false };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One simple command: its reads, its filter, its redirections; null when it is not a command (a loop header, `done`). */
|
|
389
|
+
function stageOf(words: readonly Token[], cwd: string | null, shell: 'bash' | 'powershell', depth: number): Stage | null {
|
|
390
|
+
// Redirections first: their targets are not operands.
|
|
391
|
+
const skip = new Set<Token>();
|
|
392
|
+
let outputAway = false;
|
|
393
|
+
let textInput = false;
|
|
394
|
+
const stdinFiles: Token[] = [];
|
|
395
|
+
for (let i = 0; i < words.length; i++) {
|
|
396
|
+
const w = words[i]!;
|
|
397
|
+
if (w.kind !== 'op') continue;
|
|
398
|
+
skip.add(w);
|
|
399
|
+
const target = words[i + 1];
|
|
400
|
+
if (target?.kind === 'word') skip.add(target);
|
|
401
|
+
if (OUTPUT_REDIRECTS.has(w.text)) outputAway = true;
|
|
402
|
+
else if (INPUT_REDIRECTS.has(w.text) && target?.kind === 'word') stdinFiles.push(target);
|
|
403
|
+
else if (TEXT_INPUT.has(w.text)) textInput = true;
|
|
404
|
+
}
|
|
405
|
+
let argv = words.filter((w) => w.kind === 'word' && !skip.has(w));
|
|
406
|
+
// Assignments, reserved words and wrappers lead into the command.
|
|
407
|
+
for (;;) {
|
|
408
|
+
const first = argv[0];
|
|
409
|
+
if (!first) return null;
|
|
410
|
+
if (/^[A-Za-z_]\w*=/.test(first.text)) { argv = argv.slice(1); continue; }
|
|
411
|
+
const lead = commandName(first.text);
|
|
412
|
+
if (LEADING.has(lead)) { argv = argv.slice(1); continue; }
|
|
413
|
+
if (lead === 'env' || lead === 'timeout' || lead === 'nice') {
|
|
414
|
+
let k = 1;
|
|
415
|
+
while (argv[k] && (argv[k]!.text.startsWith('-') || /^[A-Za-z_]\w*=/.test(argv[k]!.text) || (lead !== 'env' && /^\d/.test(argv[k]!.text)))) k += 1;
|
|
416
|
+
argv = argv.slice(k);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
const name = commandName(argv[0]!.text).toLowerCase();
|
|
422
|
+
const args = argv.slice(1);
|
|
423
|
+
if (HEADERS.has(name)) return null;
|
|
424
|
+
// What it reads on its standard input: a file behind `<` is read through its filter; text (`<<`) is no file.
|
|
425
|
+
const finish = (stage: Stage): Stage => {
|
|
426
|
+
const away = stage.outputAway || outputAway;
|
|
427
|
+
if (textInput) return { ...stage, readsStdin: false, outputAway: away };
|
|
428
|
+
if (!stdinFiles.length || !stage.readsStdin) return { ...stage, outputAway: away };
|
|
429
|
+
const extent = through(WHOLE, stage.filter);
|
|
430
|
+
const fromStdin = extent ? stdinFiles.flatMap((f) => filesOf(f, cwd)).map((path) => ({ path, extent })) : [];
|
|
431
|
+
return { ...stage, reads: [...stage.reads, ...fromStdin], readsStdin: false, outputAway: away };
|
|
432
|
+
};
|
|
433
|
+
if (shell === 'powershell') {
|
|
434
|
+
const ps = powerShellStage(name, args, cwd);
|
|
435
|
+
if (ps) return finish(ps);
|
|
436
|
+
}
|
|
437
|
+
// A command that shows what it reads through `filter`, from its files or else from its standard input.
|
|
438
|
+
const reading = (filter: Filter, files: readonly Token[]): Stage => {
|
|
439
|
+
const named = files.filter((f) => f.text !== '-');
|
|
440
|
+
const extent = through(WHOLE, filter);
|
|
441
|
+
const reads = extent ? named.flatMap((f) => filesOf(f, cwd)).map((path) => ({ path, extent })) : [];
|
|
442
|
+
return finish({ reads, commits: [], filter, readsStdin: named.length === 0 || files.some((f) => f.text === '-'), outputAway: false });
|
|
443
|
+
};
|
|
444
|
+
if (name === 'head' || name === 'tail') { const { filter, files } = headTail(name, args); return reading(filter, files); }
|
|
445
|
+
if (name === 'sed' || name === 'gsed') { const s = sedOf(args); return s ? reading(s.filter, s.files) : nothing(true); }
|
|
446
|
+
if (name === 'awk' || name === 'gawk' || name === 'mawk' || name === 'nawk') { const { filter, files } = awkOf(args); return reading(filter, files); }
|
|
447
|
+
if (name === 'jq' || name === 'yq') {
|
|
448
|
+
const ops = operandsOf(args, ['--arg', '--argjson', '--indent', '--slurpfile', '--rawfile']);
|
|
449
|
+
const program = ops.shift();
|
|
450
|
+
return reading(program?.text.trim() === '.' ? KEEP : SOME, ops);
|
|
451
|
+
}
|
|
452
|
+
if ((name === 'bat' || name === 'batcat') && args.some((a) => a.text === '-r' || a.text.startsWith('--line-range'))) return reading(SOME, operandsOf(args, [...VALUED.bat!, '-r', '--line-range']));
|
|
453
|
+
if (name === 'tee') return finish({ reads: [], commits: [], filter: KEEP, readsStdin: true, outputAway: false }); // its files are copies it writes
|
|
454
|
+
if (SHOWS_ALL.has(name)) return reading(KEEP, operandsOf(args, VALUED[name] ?? []));
|
|
455
|
+
if (SHOWS_SOME.has(name)) return reading(SOME, operandsOf(args, VALUED[name] ?? []));
|
|
456
|
+
if (name === 'git') return finish(gitStage(args, cwd));
|
|
457
|
+
if (BASH_FAMILY.has(name)) {
|
|
458
|
+
// `bash -c '…'`: the code is a command line of its own, read the same way; a script run is not a script read.
|
|
459
|
+
const i = args.findIndex((a) => a.text === '-c');
|
|
460
|
+
const code = i >= 0 ? args[i + 1] : undefined;
|
|
461
|
+
if (code && !code.substitution && cwd !== null && depth < 3) {
|
|
462
|
+
const inner = analyse(code.text, cwd, 'bash', depth + 1);
|
|
463
|
+
return finish({ reads: inner.reads, commits: inner.commits, filter: NONE, readsStdin: false, outputAway: false });
|
|
464
|
+
}
|
|
465
|
+
return finish(nothing(false));
|
|
466
|
+
}
|
|
467
|
+
if (SHOWS_NONE_READS_INPUT.has(name)) return finish(nothing(false, true));
|
|
468
|
+
// Anything else (`ls`, `find`, `echo`, `wc -l < f` aside, a command this does not know) shows nothing it read, and
|
|
469
|
+
// what comes down the pipe to it stops there.
|
|
470
|
+
return finish(nothing(false));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** `2>&1`, `>&2`, `1>&-`: the boundary's tokeniser splits these at the `&`; they only join streams, so they are dropped. */
|
|
474
|
+
function dropStreamJoins(tokens: readonly Token[]): Token[] {
|
|
475
|
+
const out: Token[] = [];
|
|
476
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
477
|
+
const t = tokens[i]!;
|
|
478
|
+
const amp = tokens[i + 1];
|
|
479
|
+
const fd = tokens[i + 2];
|
|
480
|
+
if (t.kind === 'op' && ['>', '1>', '2>', '>>', '1>>', '2>>'].includes(t.text) && amp?.kind === 'op' && amp.text === '&' && fd?.kind === 'word' && /^(\d+|-)$/.test(fd.text)) { i += 2; continue; }
|
|
481
|
+
out.push(t);
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const narrows = (f: Filter): boolean => f.kind !== 'keep';
|
|
487
|
+
|
|
488
|
+
/** Every file and commit a command line shows the agent, pipeline by pipeline, following `cd` as the boundary does. */
|
|
489
|
+
function analyse(command: string, startCwd: string, shell: 'bash' | 'powershell', depth = 0): { reads: Intent[]; commits: string[] } {
|
|
490
|
+
const tokens = dropStreamJoins(tokenize(extractHeredocs(command).stripped));
|
|
491
|
+
const reads: Intent[] = [];
|
|
492
|
+
const commits: string[] = [];
|
|
493
|
+
const cwdStack: (string | null)[] = [startCwd];
|
|
494
|
+
const top = () => cwdStack[cwdStack.length - 1]!;
|
|
495
|
+
let pipeline: { words: Token[]; cwd: string | null }[] = [];
|
|
496
|
+
let current: Token[] = [];
|
|
497
|
+
const flushCommand = () => { if (current.length) { pipeline.push({ words: current, cwd: top() }); current = []; } };
|
|
498
|
+
const flushPipeline = () => {
|
|
499
|
+
flushCommand();
|
|
500
|
+
const stages = pipeline.map((p) => stageOf(p.words, p.cwd, shell, depth));
|
|
501
|
+
stages.forEach((stage, i) => {
|
|
502
|
+
if (!stage) return;
|
|
503
|
+
// What this stage shows reaches the agent through every stage after it — each has to read its standard input.
|
|
504
|
+
const after = stages.slice(i + 1);
|
|
505
|
+
const follow = (e: Extent | null): Extent | null => {
|
|
506
|
+
let x = stage.outputAway ? null : e;
|
|
507
|
+
for (const next of after) {
|
|
508
|
+
if (!x) return null;
|
|
509
|
+
if (!next || !next.readsStdin) return null;
|
|
510
|
+
x = through(x, next.filter);
|
|
511
|
+
if (next.outputAway) return null;
|
|
512
|
+
}
|
|
513
|
+
return x;
|
|
514
|
+
};
|
|
515
|
+
// Several files run into one stream and then cut: which of them the cut kept is not known line by line.
|
|
516
|
+
const mixed = stage.reads.length > 1 && after.some((s) => s && narrows(s.filter));
|
|
517
|
+
for (const r of stage.reads) { const extent = follow(r.extent); if (extent) reads.push({ ...r, extent: mixed ? PART : extent }); }
|
|
518
|
+
for (const c of stage.commits) if (follow(WHOLE)) commits.push(c);
|
|
519
|
+
});
|
|
520
|
+
// `cd` alone moves the directory for what follows (in a pipeline it runs in a subshell of its own).
|
|
521
|
+
if (pipeline.length === 1) {
|
|
522
|
+
const words = pipeline[0]!.words.filter((w) => w.kind === 'word');
|
|
523
|
+
const name = words[0] ? commandName(words[0].text).toLowerCase() : '';
|
|
524
|
+
if (name === 'cd' || name === 'pushd' || name === 'set-location' || name === 'sl' || name === 'chdir') {
|
|
525
|
+
const arg = words.slice(1).find((w) => !w.text.startsWith('-') || w.text === '-');
|
|
526
|
+
const base = top();
|
|
527
|
+
cwdStack[cwdStack.length - 1] = !arg || arg.text === '-' || arg.dynamic || arg.substitution || base === null ? null : toAbsolute(arg.text, base);
|
|
528
|
+
} else if (name === 'popd') cwdStack[cwdStack.length - 1] = null;
|
|
529
|
+
}
|
|
530
|
+
pipeline = [];
|
|
531
|
+
};
|
|
532
|
+
for (const t of tokens) {
|
|
533
|
+
if (t.kind === 'op' && (t.text === '|' || t.text === '|&')) { flushCommand(); continue; }
|
|
534
|
+
if (t.kind === 'op' && t.text === '(') { flushPipeline(); cwdStack.push(top()); continue; }
|
|
535
|
+
if (t.kind === 'op' && t.text === ')') { flushPipeline(); if (cwdStack.length > 1) cwdStack.pop(); continue; }
|
|
536
|
+
if (t.kind === 'op' && [';', '&&', '||', '&', '\n', ';;'].includes(t.text)) { flushPipeline(); continue; }
|
|
537
|
+
current.push(t);
|
|
538
|
+
}
|
|
539
|
+
flushPipeline();
|
|
540
|
+
return { reads, commits };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* A file's length in lines as `sed` and `head` count them (a last line without a newline counts); null when unreadable, or
|
|
545
|
+
* larger than `maxBytes`: it is read as the step ends, on the runtime's event loop, so a large log is not read for it (a range
|
|
546
|
+
* of such a file then stays a range, read in part).
|
|
547
|
+
*/
|
|
548
|
+
export function lineCount(path: string, maxBytes = 16 * 1024 * 1024): number | null {
|
|
549
|
+
try {
|
|
550
|
+
if (statSync(path).size > maxBytes) return null;
|
|
551
|
+
const buf = readFileSync(path);
|
|
552
|
+
if (buf.length === 0) return 0;
|
|
553
|
+
let n = 0;
|
|
554
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n += 1;
|
|
555
|
+
return buf[buf.length - 1] === 10 ? n : n + 1;
|
|
556
|
+
} catch { return null; }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Ranges put in order and joined where they touch or overlap. */
|
|
560
|
+
export function mergeRanges(ranges: readonly (readonly [number, number | null])[]): [number, number | null][] {
|
|
561
|
+
const out: [number, number | null][] = [];
|
|
562
|
+
for (const [a, b] of [...ranges].sort((x, y) => x[0] - y[0])) {
|
|
563
|
+
const last = out[out.length - 1];
|
|
564
|
+
if (last && (last[1] === null || a <= last[1] + 1)) { if (last[1] !== null) last[1] = b === null ? null : Math.max(last[1], b); }
|
|
565
|
+
else out.push([a, b]);
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Whether ranges cover a file from its first line to its end: one run from line 1 to the end, or past its known length. */
|
|
571
|
+
export function coversAll(ranges: readonly (readonly [number, number | null])[], total: number | null): boolean {
|
|
572
|
+
const merged = mergeRanges(ranges);
|
|
573
|
+
if (merged.length !== 1 || merged[0]![0] > 1) return false;
|
|
574
|
+
const end = merged[0]![1];
|
|
575
|
+
return end === null || (total !== null && end >= total);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The reads of one file as the step records them: whole when a read shows all of it, or its ranges together cover it;
|
|
580
|
+
* else its ranges with the file's length; else a part.
|
|
581
|
+
*/
|
|
582
|
+
function recordFile(path: string, extents: readonly Extent[], rev?: string): StepRead[] {
|
|
583
|
+
const base = rev ? { path, rev } : { path };
|
|
584
|
+
if (extents.some((e) => e.kind === 'whole')) return [base];
|
|
585
|
+
// A version from the history has a length of its own, not the current file's.
|
|
586
|
+
const total = !rev && extents.some((e) => e.kind === 'lines' || e.kind === 'last') ? lineCount(path) : null;
|
|
587
|
+
const ranges: [number, number | null][] = [];
|
|
588
|
+
for (const e of extents) {
|
|
589
|
+
if (e.kind === 'lines') for (const [a, b] of e.ranges) ranges.push([Math.max(1, a), b]);
|
|
590
|
+
else if (e.kind === 'last' && total !== null) ranges.push([Math.max(1, total - e.count + 1), total]);
|
|
591
|
+
}
|
|
592
|
+
if (!ranges.length) return [{ ...base, part: true }];
|
|
593
|
+
if (coversAll(ranges, total)) return [base];
|
|
594
|
+
return mergeRanges(ranges).map(([a, b]) => ({ ...base, from: a, ...(b === null ? {} : { to: total !== null ? Math.min(b, total) : b }), ...(total !== null ? { lines: total } : {}) }));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** pi's shell cut its output to the tail: the agent saw only the end of what the command printed. */
|
|
598
|
+
const SHELL_OUTPUT_CUT = /\[Showing (?:lines \d+-\d+ of \d+|last [^\]]* of line \d+)[^\]]*Full output: [^\]]*\]/;
|
|
599
|
+
|
|
600
|
+
/** What a shell command showed the agent: the files (and versions) with how much of each, and the commits. */
|
|
601
|
+
export function shellReads(shell: 'bash' | 'powershell', command: string, cwd: string, output = ''): StepRead[] {
|
|
602
|
+
const { reads, commits } = analyse(command, cwd, shell);
|
|
603
|
+
const cut = SHELL_OUTPUT_CUT.test(output);
|
|
604
|
+
const byFile = new Map<string, { path: string; rev?: string; extents: Extent[] }>();
|
|
605
|
+
for (const r of reads) {
|
|
606
|
+
const key = `${r.path}\0${r.rev ?? ''}`;
|
|
607
|
+
const e = byFile.get(key) ?? { path: r.path, ...(r.rev ? { rev: r.rev } : {}), extents: [] };
|
|
608
|
+
e.extents.push(cut ? PART : r.extent);
|
|
609
|
+
byFile.set(key, e);
|
|
610
|
+
}
|
|
611
|
+
const out: StepRead[] = [];
|
|
612
|
+
for (const f of byFile.values()) out.push(...recordFile(f.path, f.extents, f.rev));
|
|
613
|
+
for (const c of new Set(commits)) out.push({ rev: c });
|
|
614
|
+
return out;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The file tool's read: from its offset to where its notice says it stopped (pi cuts at 2000 lines or 50 KB), or the end. */
|
|
618
|
+
export function fileToolReads(args: Record<string, unknown>, text: string, cwd: string): StepRead[] {
|
|
619
|
+
const given = typeof args.path === 'string' ? args.path : typeof args.file_path === 'string' ? args.file_path : null;
|
|
620
|
+
if (!given) return [];
|
|
621
|
+
if (/^\[Line \d+ is [^\]]*exceeds [^\]]*limit\. Use bash: /.test(text)) return []; // nothing shown: one line too long
|
|
622
|
+
const offset = typeof args.offset === 'number' && args.offset >= 1 ? Math.floor(args.offset) : 1;
|
|
623
|
+
const tail = text.slice(-400);
|
|
624
|
+
const showing = /\[Showing lines (\d+)-(\d+) of \d+[^\]]*\. Use offset=\d+ to continue\.\]\s*$/.exec(tail);
|
|
625
|
+
const more = /\[\d+ more lines in file\. Use offset=(\d+) to continue\.\]\s*$/.exec(tail);
|
|
626
|
+
const from = showing ? Number(showing[1]) : offset;
|
|
627
|
+
const to = showing ? Number(showing[2]) : more ? Number(more[1]) - 1 : null;
|
|
628
|
+
return recordFile(toAbsolute(given, cwd), [from <= 1 && to === null ? WHOLE : { kind: 'lines', ranges: [[from, to]] }]);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The ledger's document read: the version it showed (the current one is the file as it stands; another is the history,
|
|
633
|
+
* with its commit) and its lines. Its result is the version's fields as JSON, read field by field so a result cut short
|
|
634
|
+
* still says what it showed.
|
|
635
|
+
*/
|
|
636
|
+
export function ledgerDocReads(text: string, repoRoot: (repo: string) => string | null): StepRead[] {
|
|
637
|
+
if (/^ERROR:/.test(text)) return [];
|
|
638
|
+
const head = text.slice(0, 4000);
|
|
639
|
+
const field = (name: string): string | null => new RegExp(`"${name}":\\s*("(?:[^"\\\\]|\\\\.)*"|true|false|\\d+)`).exec(head)?.[1] ?? null;
|
|
640
|
+
const str = (name: string): string | null => { const v = field(name); try { return v?.startsWith('"') ? JSON.parse(v) as string : null; } catch { return null; } };
|
|
641
|
+
const num = (name: string): number | null => { const v = field(name); return v !== null && /^\d+$/.test(v) ? Number(v) : null; };
|
|
642
|
+
const repo = str('repo');
|
|
643
|
+
const rel = str('path');
|
|
644
|
+
const root = repo ? repoRoot(repo) : null;
|
|
645
|
+
if (!root || !rel) return [];
|
|
646
|
+
const path = join(root, ...rel.split('/'));
|
|
647
|
+
const base = field('current') === 'true' ? { path } : { path, rev: str('commit') ?? '' };
|
|
648
|
+
const from = num('fromLine') ?? 1;
|
|
649
|
+
if (from <= 1 && field('nextFromLine') === null) return [base];
|
|
650
|
+
const to = num('toLine');
|
|
651
|
+
const total = num('lines');
|
|
652
|
+
return [{ ...base, from, ...(to !== null ? { to } : {}), ...(total !== null ? { lines: total } : {}) }];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** The ledger's one-commit read: the commit, by the full hash it answered with (or else the one asked for). */
|
|
656
|
+
export function ledgerCommitReads(args: Record<string, unknown>, text: string): StepRead[] {
|
|
657
|
+
if (/^ERROR:/.test(text)) return [];
|
|
658
|
+
const full = /"hash":\s*"([0-9a-f]{7,40})"/.exec(text.slice(0, 2000))?.[1];
|
|
659
|
+
const asked = typeof args.hash === 'string' ? args.hash.trim().replace(/^commit:/, '').toLowerCase() : '';
|
|
660
|
+
const hash = full ?? (/^[0-9a-f]{7,40}$/.test(asked) ? asked : '');
|
|
661
|
+
return hash ? [{ rev: hash }] : [];
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The ledger's session read (`pk_ledger_sessions` with a session): the session, by the ledger's id, and the positions of
|
|
666
|
+
* the messages it showed (the ledger's message index plus one) — out of how many there are when the page was taken by
|
|
667
|
+
* offset, since then its total is the session's. A page taken by speaker shows some of the messages, not a run of them.
|
|
668
|
+
* The result is read for its row indexes, so a page cut at the tool's size limit still says what reached the agent. A
|
|
669
|
+
* list of sessions (no session asked for) reads none of them.
|
|
670
|
+
*/
|
|
671
|
+
export function ledgerSessionReads(args: Record<string, unknown>, text: string): StepRead[] {
|
|
672
|
+
if (/^ERROR:/.test(text) || typeof args.session !== 'string' || !args.session.trim()) return [];
|
|
673
|
+
const session = /"session":\s*\{\s*"id":\s*"(session:[^"]+)"/.exec(text.slice(0, 2000))?.[1] ?? /"session":\s*"(session:[^"]+)"/.exec(text)?.[1];
|
|
674
|
+
if (!session) return [];
|
|
675
|
+
const speaker = typeof args.speaker === 'string' && args.speaker.trim() !== '';
|
|
676
|
+
const indexes = [...text.matchAll(/"index":\s*(\d+)/g)].map((m) => Number(m[1]));
|
|
677
|
+
if (!indexes.length) return [];
|
|
678
|
+
if (speaker) return [{ session, part: true }];
|
|
679
|
+
const from = Math.min(...indexes) + 1;
|
|
680
|
+
const to = Math.max(...indexes) + 1;
|
|
681
|
+
const byIndex = typeof args.fromIndex === 'number' || (typeof args.fromIndex === 'string' && args.fromIndex.trim() !== '');
|
|
682
|
+
const total = /"total":\s*(\d+)/.exec(text)?.[1];
|
|
683
|
+
return [{ session, from, to, ...(!byIndex && total !== undefined ? { lines: Number(total) } : {}) }];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Where a step's reads are resolved: the directory the tools run in, and the ledger's repositories by id. */
|
|
687
|
+
export interface StepReadContext {
|
|
688
|
+
readonly cwd: string;
|
|
689
|
+
readonly repoRoot?: (repo: string) => string | null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The tools whose reads are recorded; the others read nothing a coverage counts this way (`pk_read_source` counts by its
|
|
694
|
+
* target). A session read through the ledger is recorded by the session (`StepRead.session`).
|
|
695
|
+
*/
|
|
696
|
+
export const READING_TOOLS: ReadonlySet<string> = new Set(['read', 'bash', 'powershell', 'pk_ledger_doc_read', 'pk_ledger_commit', 'pk_ledger_sessions']);
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* What a step read, from its call and its result (`JobStep.reads`); null for a tool not in `READING_TOOLS`. A failed call
|
|
700
|
+
* read nothing — except a shell command that ran and exited non-zero, whose output the agent saw all the same.
|
|
701
|
+
*/
|
|
702
|
+
export function stepReads(tool: string, args: Record<string, unknown> | null | undefined, text: string, isError: boolean, ctx: StepReadContext): StepRead[] | null {
|
|
703
|
+
if (!READING_TOOLS.has(tool)) return null;
|
|
704
|
+
const a = args ?? {};
|
|
705
|
+
try {
|
|
706
|
+
if (tool === 'bash' || tool === 'powershell') {
|
|
707
|
+
const ran = !isError || /Command exited with code \d+\s*$/.test(text);
|
|
708
|
+
const command = typeof a.command === 'string' ? a.command : '';
|
|
709
|
+
return ran && command ? shellReads(tool, command, ctx.cwd, text) : [];
|
|
710
|
+
}
|
|
711
|
+
if (isError) return [];
|
|
712
|
+
if (tool === 'read') return fileToolReads(a, text, ctx.cwd);
|
|
713
|
+
if (tool === 'pk_ledger_doc_read') return ctx.repoRoot ? ledgerDocReads(text, ctx.repoRoot) : [];
|
|
714
|
+
if (tool === 'pk_ledger_commit') return ledgerCommitReads(a, text);
|
|
715
|
+
if (tool === 'pk_ledger_sessions') return ledgerSessionReads(a, text);
|
|
716
|
+
} catch { /* what a step read is evidence; a call it cannot make out records nothing rather than failing the job */ }
|
|
717
|
+
return [];
|
|
718
|
+
}
|