@salaros/ai-harness 0.2.10 → 0.3.1
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/package.json +3 -1
- package/scripts/README.md +16 -5
- package/scripts/lib.js +44 -15
- package/scripts/project-facts.js +113 -0
- package/scripts/repo-view.js +101 -0
- package/scripts/update-harness.js +434 -323
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salaros/ai-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Installs and updates the agent harness from its upstream repository: hooks, skills, agents and the documentation chain, merged into an existing repository without touching its own work.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ai-harness": "scripts/update-harness.js"
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"scripts/update-harness.js",
|
|
10
10
|
"scripts/lib.js",
|
|
11
|
+
"scripts/project-facts.js",
|
|
12
|
+
"scripts/repo-view.js",
|
|
11
13
|
"README.md",
|
|
12
14
|
"LICENSE"
|
|
13
15
|
],
|
package/scripts/README.md
CHANGED
|
@@ -17,11 +17,13 @@ part of the product.
|
|
|
17
17
|
|
|
18
18
|
- One task per script, named after what it does (`githooks-init.js`,
|
|
19
19
|
`build.js`, `release.js`); Node, so they run the same on every OS.
|
|
20
|
-
- Scripts are safe to run from any working directory —
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
- Scripts are safe to run from any working directory — every script and hook
|
|
21
|
+
asks `lib.root()` which repo it is about, and gets one answer: `--root=<dir>`
|
|
22
|
+
when given, then the harness's project-dir variable
|
|
23
|
+
(`CLAUDE_PROJECT_DIR` and its Cursor and Gemini equivalents), then the
|
|
24
|
+
checkout the file sits in (`lib.args()` is the arguments without the flag).
|
|
25
|
+
The suite uses the flag to run a script against a temporary repo, and a
|
|
26
|
+
script that calls another passes its own root along.
|
|
25
27
|
- A script run as a command may `chdir` there (`lib.chdirRoot()`). A script
|
|
26
28
|
another script requires takes the root as its first argument and resolves
|
|
27
29
|
against it (`lib.root()` at the entry point, `path.resolve(root, …)`
|
|
@@ -36,6 +38,15 @@ part of the product.
|
|
|
36
38
|
and hand over to `githook.js <hook>`. Nothing else belongs in them: a
|
|
37
39
|
decision made in the shell is one no test can reach, and the suite asserts
|
|
38
40
|
the shape.
|
|
41
|
+
- A script is a thin shell around its decisions. Parsing, merging,
|
|
42
|
+
formatting and the summary it prints go in a pure module the suite tests;
|
|
43
|
+
the script only reads, writes and reports, for the same reason the hooks
|
|
44
|
+
above hold no logic.
|
|
45
|
+
- What two scripts share lives in one module here instead of a copy in each,
|
|
46
|
+
so the callers cannot drift: a threshold, a list, or a whole step, the way
|
|
47
|
+
`lib.js` holds root resolution and argument parsing for every script. When
|
|
48
|
+
several scripts do one job for different sources, a runner owns the common
|
|
49
|
+
steps and each script brings only its source and its rules.
|
|
39
50
|
- Scripts must be idempotent where possible — running them twice should not
|
|
40
51
|
break anything.
|
|
41
52
|
|
package/scripts/lib.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// which requires this file rather than the other way around, so scripts/ never reaches into the
|
|
5
5
|
// harness-specific folder.
|
|
6
6
|
// const lib = require("./lib");
|
|
7
|
-
// const root = lib.root(); // the repo root
|
|
7
|
+
// const root = lib.root(); // the repo root (precedence below)
|
|
8
8
|
// const root = lib.chdirRoot(); // the same, and cd there
|
|
9
9
|
// const [cmd, ...rest] = lib.args(); // the command line, without --root=<dir>
|
|
10
10
|
// lib.stdin() // everything on stdin, or "" if there is none
|
|
@@ -15,21 +15,50 @@ const fs = require("fs");
|
|
|
15
15
|
const path = require("path");
|
|
16
16
|
const { spawnSync } = require("child_process");
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
18
|
+
// Which repo an entry point is about, in one precedence every script and hook follows:
|
|
19
|
+
// 1. --root=<dir> on the command line, the explicit answer
|
|
20
|
+
// 2. CLAUDE_PROJECT_DIR, CURSOR_PROJECT_DIR or GEMINI_PROJECT_DIR, the harness's answer
|
|
21
|
+
// 3. the checkout this file sits in
|
|
22
|
+
// The flag is how a check reaches a repo other than its own checkout: the suite points a case at a
|
|
23
|
+
// throwaway directory rather than planting files in the tree it runs in, and the installer checks a
|
|
24
|
+
// target with the upstream's copy of a script. One token, so an argument parser that skips "--"
|
|
25
|
+
// flags skips it too. The variables are how a harness says which project it is editing, which is
|
|
26
|
+
// not always the checkout a hook file sits in. A variable naming another checkout is honoured and
|
|
27
|
+
// reported on stderr; one naming nothing that exists is ignored and reported.
|
|
28
|
+
// A script run as a command chdirs to the root so its relative paths (SKILL.md files, README.md,
|
|
29
|
+
// stacks.tsv) work wherever it was invoked from. A script another script requires takes the root as
|
|
30
|
+
// an argument and resolves against it: chdir is a process-wide effect, so a library that moves the
|
|
31
|
+
// working directory moves it for its caller too.
|
|
28
32
|
const ROOT_FLAG = "--root=";
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
const ROOT_ENV_VARS = ["CLAUDE_PROJECT_DIR", "CURSOR_PROJECT_DIR", "GEMINI_PROJECT_DIR"];
|
|
34
|
+
const CHECKOUT = path.resolve(__dirname, "..");
|
|
35
|
+
|
|
36
|
+
const win = process.platform === "win32";
|
|
37
|
+
const warn = msg => process.stderr.write(`harness: ${msg}\n`);
|
|
38
|
+
const same = (a, b) => win ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
39
|
+
// Git Bash reports C:\x as /c/x; Windows APIs may prefix \\?\. Bring both to a form path can resolve.
|
|
40
|
+
const fix = p => {
|
|
41
|
+
let s = String(p).replace(/^\\\\\?\\/, "");
|
|
42
|
+
if (win) { const m = s.match(/^\/([a-zA-Z])(?:\/(.*))?$/); if (m) s = `${m[1].toUpperCase()}:/${m[2] || ""}`; }
|
|
43
|
+
return s;
|
|
32
44
|
};
|
|
45
|
+
const real = p => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
46
|
+
|
|
47
|
+
function root() {
|
|
48
|
+
const given = process.argv.slice(2).find(a => a.startsWith(ROOT_FLAG));
|
|
49
|
+
if (given) return path.resolve(fix(given.slice(ROOT_FLAG.length)));
|
|
50
|
+
const here = real(CHECKOUT);
|
|
51
|
+
for (const v of ROOT_ENV_VARS) {
|
|
52
|
+
const val = process.env[v];
|
|
53
|
+
if (!val) continue;
|
|
54
|
+
const dir = path.resolve(fix(val));
|
|
55
|
+
let st; try { st = fs.statSync(dir); } catch { }
|
|
56
|
+
if (!st || !st.isDirectory()) { warn(`${v} is not a directory; using the checkout this file lives in`); break; }
|
|
57
|
+
if (!same(real(dir), here)) warn(`${v} (${dir}) is not the checkout this file lives in (${here}); using ${v}`);
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
return here;
|
|
61
|
+
}
|
|
33
62
|
const args = () => process.argv.slice(2).filter(a => !a.startsWith(ROOT_FLAG));
|
|
34
63
|
|
|
35
64
|
function chdirRoot() {
|
|
@@ -55,4 +84,4 @@ function readTsv(file) {
|
|
|
55
84
|
.map(l => l.split("\t"));
|
|
56
85
|
}
|
|
57
86
|
|
|
58
|
-
module.exports = { root, args, ROOT_FLAG, chdirRoot, stdin, run, node, shell, readTsv };
|
|
87
|
+
module.exports = { root, args, ROOT_FLAG, ROOT_ENV_VARS, CHECKOUT, chdirRoot, fix, warn, stdin, run, node, shell, readTsv };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// scripts/project-facts.js
|
|
2
|
+
// The one reader of a project's facts: the labelled lines of MEMORY.md, and the name and purpose an
|
|
3
|
+
// INTENT.md gives in its "## Product" section. The initialisation gate, the commit-message check,
|
|
4
|
+
// docs-check, the docs portal and the installer's MEMORY.md skeleton all ask this module, so a line
|
|
5
|
+
// one of them counts as present is present to every one of them, and a placeholder one of them
|
|
6
|
+
// treats as unanswered is unanswered everywhere.
|
|
7
|
+
// Shipped in the npm package beside update-harness.js and lib.js, because the installer builds its
|
|
8
|
+
// skeleton from FACTS, so the installer must not require anything else from scripts/.
|
|
9
|
+
// Usage:
|
|
10
|
+
// const facts = require("./project-facts");
|
|
11
|
+
// const f = facts.readFactsAt(root); // { Name, Purpose, Requirements, ... }
|
|
12
|
+
// f["Issue tracker"] // null: no line; "": unanswered; else the value
|
|
13
|
+
// facts.unanswered(f) // the required labels with no answer
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
|
|
17
|
+
const MEMORY = "MEMORY.md";
|
|
18
|
+
const INTENT = "INTENT.md";
|
|
19
|
+
|
|
20
|
+
// Every fact, in the order MEMORY.md lists them. `required` facts gate initialisation; the rest are
|
|
21
|
+
// read by the skills that need them and never block. `intent` marks the two an INTENT.md owns.
|
|
22
|
+
// `Issue tracker` is optional because a tracker is a choice rather than a property of the code,
|
|
23
|
+
// `Frontend` because a service or a library has no UI, and `Prose language` because a missing line
|
|
24
|
+
// already means English.
|
|
25
|
+
const FACTS = [
|
|
26
|
+
{ label: "Name", required: true, intent: true, placeholder: "<name>" },
|
|
27
|
+
{ label: "Purpose", required: true, intent: true, placeholder: "<purpose>" },
|
|
28
|
+
{ label: "Prose language", required: false, placeholder: "<prose language>" },
|
|
29
|
+
{ label: "Requirements", required: true, placeholder: "<requirements>" },
|
|
30
|
+
{ label: "Unit type", required: true, placeholder: "<unit type>" },
|
|
31
|
+
{ label: "Language", required: true, placeholder: "<language>" },
|
|
32
|
+
{ label: "Runtime / package manager", required: true, placeholder: "<runtime>" },
|
|
33
|
+
{ label: "Frontend", required: false, placeholder: "<framework>" },
|
|
34
|
+
{ label: "Issue tracker", required: false, placeholder: "<tracker>" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// INTENT.md (https://www.intentdocs.com/intent-md): the product's intent, one per product, at the
|
|
38
|
+
// root. Optional, so the harness neither ships nor requires one. Given its text, returns whether the
|
|
39
|
+
// "# INTENT.md" title is there, the product's bold name and the prose describing it from
|
|
40
|
+
// "## Product" (null when the section is missing), and whether "## MVP stories" is there; that heading
|
|
41
|
+
// may run on, as in "## MVP stories — build these first". docs-check holds the file to these sections.
|
|
42
|
+
function readIntent(text) {
|
|
43
|
+
const lines = text.split(/\r?\n/);
|
|
44
|
+
const h1 = lines.find(l => /^#\s/.test(l));
|
|
45
|
+
const section = name => {
|
|
46
|
+
const start = lines.findIndex(l => new RegExp(`^##\\s+${name}\\b`, "i").test(l));
|
|
47
|
+
if (start < 0) return null;
|
|
48
|
+
const end = lines.findIndex((l, i) => i > start && /^#{1,2}\s/.test(l));
|
|
49
|
+
return lines.slice(start + 1, end < 0 ? lines.length : end).join("\n").trim();
|
|
50
|
+
};
|
|
51
|
+
const body = section("Product");
|
|
52
|
+
let product = null;
|
|
53
|
+
if (body !== null) {
|
|
54
|
+
const bold = body.match(/\*\*([^*]+)\*\*/);
|
|
55
|
+
product = {
|
|
56
|
+
name: bold ? bold[1].trim().replace(/[:.]+$/, "").trim() : "",
|
|
57
|
+
purpose: body.replace(/\*\*[^*]+\*\*/, "").replace(/^[\s:.,—–-]+/, "").replace(/\s+/g, " ").trim(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { title: !!h1 && h1.trim() === "# INTENT.md", product, stories: section("MVP stories") !== null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A fact's line: a `-` or `*` bullet, then the label in bold with its colon inside or after the bold
|
|
64
|
+
// (`- **Name:** Acme`, `* **Name**: Acme`). An unbolded line is prose that happens to start with the
|
|
65
|
+
// word, so it is not a fact. The value is null when no line carries the label.
|
|
66
|
+
function lineValue(text, label) {
|
|
67
|
+
if (text === null) return null;
|
|
68
|
+
const escaped = label.replace(/[/.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
const m = text.match(new RegExp(`^\\s*[-*]\\s*\\*\\*${escaped}(?::\\*\\*|\\*\\*:)[ \\t]*(.*)$`, "mi"));
|
|
70
|
+
return m ? m[1].trim() : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A value is unanswered when nothing is left once every <placeholder> is removed: `<language>` and
|
|
74
|
+
// `<language> <version>` are the template, `C#` and `see <link> in docs/` are answers, kept whole.
|
|
75
|
+
const answer = value => value.replace(/<[^>]*>/g, "").trim() ? value : "";
|
|
76
|
+
|
|
77
|
+
// Every fact, from the texts of MEMORY.md and INTENT.md, each null when that file is absent. A label
|
|
78
|
+
// maps to null when nothing gives it, "" when it is unanswered, and the trimmed value otherwise.
|
|
79
|
+
// With an INTENT.md, Name and Purpose come from its "## Product" alone: a MEMORY.md line for either
|
|
80
|
+
// is ignored, so a stale copy there never stands in for the product's own words.
|
|
81
|
+
function readFacts({ memory = null, intent = null } = {}) {
|
|
82
|
+
const product = intent === null ? null : readIntent(intent).product;
|
|
83
|
+
const facts = {};
|
|
84
|
+
for (const { label, intent: owned } of FACTS) {
|
|
85
|
+
const raw = owned && intent !== null
|
|
86
|
+
? (product ? product[label.toLowerCase()] : null)
|
|
87
|
+
: lineValue(memory, label);
|
|
88
|
+
facts[label] = raw === null ? null : answer(raw);
|
|
89
|
+
}
|
|
90
|
+
return facts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// readFacts() for the files on disk under a root. A reader holding the texts some other way, such as
|
|
94
|
+
// docs-check through a repo view, calls readFacts() with them instead.
|
|
95
|
+
function readFactsAt(root) {
|
|
96
|
+
const read = file => {
|
|
97
|
+
const at = path.resolve(root, file);
|
|
98
|
+
return fs.existsSync(at) ? fs.readFileSync(at, "utf8") : null;
|
|
99
|
+
};
|
|
100
|
+
return readFacts({ memory: read(MEMORY), intent: read(INTENT) });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The required labels readFacts() found no answer for, in FACTS order.
|
|
104
|
+
const unanswered = facts => FACTS.filter(f => f.required && !facts[f.label]).map(f => f.label);
|
|
105
|
+
|
|
106
|
+
// MEMORY.md's fact lines as the installer lays them down, every one a placeholder. Beside an
|
|
107
|
+
// INTENT.md, Name and Purpose give way to a line saying where they are.
|
|
108
|
+
function skeleton(hasIntent) {
|
|
109
|
+
const lines = FACTS.filter(f => !(hasIntent && f.intent)).map(f => `- **${f.label}:** ${f.placeholder}`);
|
|
110
|
+
return hasIntent ? [`The name and purpose are in \`${INTENT}\`, under \`## Product\`.`, "", ...lines] : lines;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { FACTS, MEMORY, INTENT, readFacts, readFactsAt, unanswered, readIntent, skeleton };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// scripts/repo-view.js
|
|
2
|
+
// A repo as a check reads it: four questions about repo-relative paths, answered from the working
|
|
3
|
+
// tree, from Git's index, or from a map a test builds. A check that takes a view reads what a
|
|
4
|
+
// commit will record as easily as what is on disk, and a test hands it the files it is about
|
|
5
|
+
// instead of building a tree or pointing it at a file that does not exist.
|
|
6
|
+
// exists(rel) a file, or a folder holding one
|
|
7
|
+
// isFile(rel) a file
|
|
8
|
+
// read(rel) its text, or null when there is none
|
|
9
|
+
// list(rel) the names directly inside a folder, sorted; [] when there is none
|
|
10
|
+
// Paths are repo-relative with forward slashes. Nothing here writes, prints or exits.
|
|
11
|
+
// indexModes() is the one reader of `git ls-files -s`: the mode Git records is how the harness
|
|
12
|
+
// tells a symlink (120000) and an executable (100755) from a plain file.
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const { spawnSync } = require("child_process");
|
|
16
|
+
|
|
17
|
+
// stdout alone and untrimmed, since a blob's trailing newline is part of its text.
|
|
18
|
+
const git = (dir, args) => spawnSync("git", ["-c", "core.quotepath=off", ...args], { cwd: dir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
19
|
+
|
|
20
|
+
const norm = rel => path.posix.normalize(String(rel).split(path.sep).join("/")).replace(/^\.\/?$|\/+$/g, "");
|
|
21
|
+
|
|
22
|
+
// Every path Git's index holds in `dir`, optionally under `paths` (pathspecs), as
|
|
23
|
+
// { file, mode, object, link, exec }. null when `dir` is not a git checkout.
|
|
24
|
+
function indexModes(dir, paths = []) {
|
|
25
|
+
const r = git(dir, ["ls-files", "-s", "-z", ...(paths.length ? ["--", ...paths] : [])]);
|
|
26
|
+
if (r.status !== 0) return null;
|
|
27
|
+
return r.stdout.split("\0").filter(Boolean).map(entry => {
|
|
28
|
+
const [meta, file] = entry.split("\t");
|
|
29
|
+
const [mode, object] = meta.split(" ");
|
|
30
|
+
return { file, mode, object, link: mode === "120000", exec: mode === "100755" };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A view over a set of files known up front, each read on first use. Folders are whatever the
|
|
35
|
+
// paths imply, which is all an index or a map has of them.
|
|
36
|
+
function filesView(loaders) {
|
|
37
|
+
const cache = new Map();
|
|
38
|
+
const under = rel => rel ? rel + "/" : "";
|
|
39
|
+
return {
|
|
40
|
+
exists: rel => { rel = norm(rel); return loaders.has(rel) || [...loaders.keys()].some(f => f.startsWith(under(rel))); },
|
|
41
|
+
isFile: rel => loaders.has(norm(rel)),
|
|
42
|
+
read: rel => {
|
|
43
|
+
rel = norm(rel);
|
|
44
|
+
if (!loaders.has(rel)) return null;
|
|
45
|
+
if (!cache.has(rel)) cache.set(rel, loaders.get(rel)());
|
|
46
|
+
return cache.get(rel);
|
|
47
|
+
},
|
|
48
|
+
list: rel => {
|
|
49
|
+
const prefix = under(norm(rel));
|
|
50
|
+
const names = new Set([...loaders.keys()].filter(f => f.startsWith(prefix)).map(f => f.slice(prefix.length).split("/")[0]));
|
|
51
|
+
return [...names].sort();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The files on disk under `root`. A path outside it resolves as it stands, which is what a cited
|
|
57
|
+
// `../` path means.
|
|
58
|
+
function worktree(root) {
|
|
59
|
+
const at = rel => path.resolve(root, String(rel));
|
|
60
|
+
const stat = rel => { try { return fs.statSync(at(rel)); } catch { return null; } };
|
|
61
|
+
return {
|
|
62
|
+
exists: rel => !!stat(rel),
|
|
63
|
+
isFile: rel => !!(stat(rel) || { isFile: () => false }).isFile(),
|
|
64
|
+
read: rel => { const s = stat(rel); return s && s.isFile() ? fs.readFileSync(at(rel), "utf8") : null; },
|
|
65
|
+
list: rel => { const s = stat(rel); return s && s.isDirectory() ? fs.readdirSync(at(rel)).sort() : []; },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Git's index at `root`, limited to `paths` when given: what the next commit records, and nothing
|
|
70
|
+
// the working tree holds besides. Throws when `root` is not a git checkout, since an index nobody
|
|
71
|
+
// could read is not an empty one.
|
|
72
|
+
function index(root, paths = []) {
|
|
73
|
+
const rows = indexModes(root, paths);
|
|
74
|
+
if (!rows) throw new Error(`could not read the git index in ${root}`);
|
|
75
|
+
const blob = object => {
|
|
76
|
+
const r = git(root, ["cat-file", "blob", object]);
|
|
77
|
+
if (r.status !== 0) throw new Error(`could not read blob ${object}: ${r.stderr}`);
|
|
78
|
+
return r.stdout;
|
|
79
|
+
};
|
|
80
|
+
return filesView(new Map(rows.map(row => [row.file, () => blob(row.object)])));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// What a commit will record for `paths`, and the working tree for everything else. Each of `paths`
|
|
84
|
+
// is read from the index when the index holds anything under it, and from the disk when it holds
|
|
85
|
+
// nothing: a repo that keeps one of them untracked still gets a meaningful answer.
|
|
86
|
+
function staged(root, paths) {
|
|
87
|
+
const idx = index(root, paths), disk = worktree(root);
|
|
88
|
+
const owner = rel => { rel = norm(rel); return paths.map(norm).find(p => rel === p || rel.startsWith(p + "/")); };
|
|
89
|
+
const pick = rel => { const p = owner(rel); return p !== undefined && idx.exists(p) ? idx : disk; };
|
|
90
|
+
return {
|
|
91
|
+
exists: rel => pick(rel).exists(rel),
|
|
92
|
+
isFile: rel => pick(rel).isFile(rel),
|
|
93
|
+
read: rel => pick(rel).read(rel),
|
|
94
|
+
list: rel => pick(rel).list(rel),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The files a test names, path -> text, and nothing else.
|
|
99
|
+
const fromMap = files => filesView(new Map(Object.entries(files).map(([f, text]) => [norm(f), () => text])));
|
|
100
|
+
|
|
101
|
+
module.exports = { worktree, index, staged, fromMap, indexModes };
|