@jitsusama/agentic-harness.core 0.6.3 → 0.6.4
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/dist/command/tokenize.js +51 -5
- package/dist/internal/quest/bash-effects.d.ts +26 -0
- package/dist/internal/quest/bash-effects.js +477 -0
- package/dist/internal/quest/bash-write.d.ts +37 -0
- package/dist/internal/quest/bash-write.js +311 -37
- package/dist/internal/quest/record-audit.d.ts +43 -0
- package/dist/internal/quest/record-audit.js +126 -0
- package/dist/internal/quest/record-gate.d.ts +36 -0
- package/dist/internal/quest/record-gate.js +101 -0
- package/dist/internal/quest/record.d.ts +112 -0
- package/dist/internal/quest/record.js +284 -0
- package/dist/internal/quest/workspace.d.ts +32 -0
- package/dist/internal/quest/workspace.js +51 -0
- package/package.json +7 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record's rules for a single write, applied before it lands.
|
|
3
|
+
*
|
|
4
|
+
* A write is refused only when the rule it breaks is certain from the
|
|
5
|
+
* path alone, and every refusal says where the write belongs instead, so
|
|
6
|
+
* the refusal is a redirection rather than a dead end. What a path cannot
|
|
7
|
+
* decide (an attachment's size, whether it is text, whether a document
|
|
8
|
+
* cites it) is left to the check on disk after the write.
|
|
9
|
+
*
|
|
10
|
+
* The rules hold in every quest's folder, not only the loaded quest's,
|
|
11
|
+
* since a write into another quest's record breaks that record just the
|
|
12
|
+
* same.
|
|
13
|
+
*/
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
import { ATTACHMENTS_FOLDER, attachmentNameProblem, DOCUMENT_FOLDERS, questRecordPath, } from "./record.js";
|
|
16
|
+
import { questWorkspace } from "./workspace.js";
|
|
17
|
+
/** Characters that make a path's name a pattern rather than a name. */
|
|
18
|
+
const GLOB = /[*?[]/;
|
|
19
|
+
/** Judge one write or removal against the record's rules. */
|
|
20
|
+
export function judgeRecordWrite(write, roots) {
|
|
21
|
+
const place = questRecordPath(roots.questsRoot, write.path);
|
|
22
|
+
if (!place)
|
|
23
|
+
return undefined;
|
|
24
|
+
return write.effect === "remove"
|
|
25
|
+
? judgeRemoval(place)
|
|
26
|
+
: judgeWrite(place, write, roots);
|
|
27
|
+
}
|
|
28
|
+
function judgeRemoval(place) {
|
|
29
|
+
const { place: kind, rel } = place;
|
|
30
|
+
const kept = kind === "document" ||
|
|
31
|
+
kind === "readme" ||
|
|
32
|
+
kind === "folder" ||
|
|
33
|
+
kind === "quest" ||
|
|
34
|
+
(kind === "stray" && isKindFolderPattern(rel));
|
|
35
|
+
if (!kept)
|
|
36
|
+
return undefined;
|
|
37
|
+
const what = kind === "quest" ? `the quest ${place.quest}` : rel;
|
|
38
|
+
return {
|
|
39
|
+
rule: "document-removed",
|
|
40
|
+
reason: `This removes ${what}, which is part of ${place.quest}'s record. ` +
|
|
41
|
+
"Documents, the README and the record's folders leave only through " +
|
|
42
|
+
"`quest retire`, which keeps what was written.",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function judgeWrite(place, write, roots) {
|
|
46
|
+
const { place: kind, rel } = place;
|
|
47
|
+
if (kind === "quest" || kind === "folder")
|
|
48
|
+
return undefined;
|
|
49
|
+
if (kind === "document" || kind === "readme") {
|
|
50
|
+
if (write.via === "bash") {
|
|
51
|
+
return {
|
|
52
|
+
rule: "document-by-bash",
|
|
53
|
+
reason: `This bash command writes into ${rel}. Change a document or the ` +
|
|
54
|
+
"README with the edit or write tool instead, so the change is one " +
|
|
55
|
+
"the quest's own checks read.",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (!write.exists) {
|
|
59
|
+
return {
|
|
60
|
+
rule: "document-by-hand",
|
|
61
|
+
reason: kind === "readme"
|
|
62
|
+
? `${place.quest} has no README to change. A quest and its README are made by \`quest create\`.`
|
|
63
|
+
: `${rel} is a new document. Documents are made by \`quest draft\`, which mints the ID and the front matter; run \`quest think\` and then \`quest draft\` instead.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const workspace = questWorkspace(roots.workspaceRoot, place.quest).dir;
|
|
69
|
+
if (kind === "attachment") {
|
|
70
|
+
const problem = attachmentNameProblem(rel);
|
|
71
|
+
if (!problem)
|
|
72
|
+
return undefined;
|
|
73
|
+
const instead = join(workspace, rel.slice(ATTACHMENTS_FOLDER.length + 1));
|
|
74
|
+
return {
|
|
75
|
+
rule: "attachment",
|
|
76
|
+
reason: `${rel} is ${problem.detail}, and attachments hold only text and ` +
|
|
77
|
+
`images a document cites. Put it in the quest's workspace instead: ${instead}`,
|
|
78
|
+
instead,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const instead = join(workspace, rel);
|
|
82
|
+
const citable = attachmentNameProblem(rel) === undefined;
|
|
83
|
+
return {
|
|
84
|
+
rule: "stray",
|
|
85
|
+
reason: `${rel} is not part of ${place.quest}'s record, which holds only its ` +
|
|
86
|
+
"README, its documents and attachments/. Put it in the quest's " +
|
|
87
|
+
`workspace instead: ${instead}` +
|
|
88
|
+
(citable
|
|
89
|
+
? `. If a document will cite it, ${ATTACHMENTS_FOLDER}/${basename(rel)} is its place.`
|
|
90
|
+
: ""),
|
|
91
|
+
instead,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Whether a path is a pattern inside a kind folder, which may name documents. */
|
|
95
|
+
function isKindFolderPattern(rel) {
|
|
96
|
+
const [top, name, ...deeper] = rel.split("/");
|
|
97
|
+
return (deeper.length === 0 &&
|
|
98
|
+
name !== undefined &&
|
|
99
|
+
GLOB.test(name) &&
|
|
100
|
+
DOCUMENT_FOLDERS.includes(top ?? ""));
|
|
101
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A quest's record: what in a quest folder is kept, backed up and
|
|
3
|
+
* protected, and what is not.
|
|
4
|
+
*
|
|
5
|
+
* The record is the README, the documents (an ID-named markdown file
|
|
6
|
+
* directly in a kind folder), and one shared `attachments/` folder that
|
|
7
|
+
* any document may cite. Everything else a quest makes (clones, raw
|
|
8
|
+
* data, labs, runs, builds) belongs in its workspace outside the quests
|
|
9
|
+
* folder, so anything else found inside one is a stray.
|
|
10
|
+
*
|
|
11
|
+
* The backup in the dotfiles (`quest-backup`) takes exactly this record
|
|
12
|
+
* and must work without pi, so it keeps its own copy of these rules. A
|
|
13
|
+
* change here needs the same change there.
|
|
14
|
+
*/
|
|
15
|
+
/** The folders a quest's documents live in, one per kind. */
|
|
16
|
+
export declare const DOCUMENT_FOLDERS: readonly ["plans", "research", "briefs", "reports"];
|
|
17
|
+
/** The folder a quest's attachments live in. */
|
|
18
|
+
export declare const ATTACHMENTS_FOLDER = "attachments";
|
|
19
|
+
/**
|
|
20
|
+
* What a path is within a quest's record.
|
|
21
|
+
*
|
|
22
|
+
* - `quest`: the quest folder itself.
|
|
23
|
+
* - `readme`: its README.
|
|
24
|
+
* - `document`: an ID-named markdown file directly in a kind folder.
|
|
25
|
+
* - `folder`: a kind folder or the attachments folder itself.
|
|
26
|
+
* - `attachment`: anything beneath the attachments folder.
|
|
27
|
+
* - `stray`: anything else, which belongs in the workspace.
|
|
28
|
+
*/
|
|
29
|
+
export type RecordPlace = "quest" | "readme" | "document" | "folder" | "attachment" | "stray";
|
|
30
|
+
/** A path inside a quest folder, and what it is there. */
|
|
31
|
+
export interface QuestRecordPath {
|
|
32
|
+
/** The quest's ID. */
|
|
33
|
+
readonly quest: string;
|
|
34
|
+
/** The quest's folder. */
|
|
35
|
+
readonly questDir: string;
|
|
36
|
+
/** The path relative to the quest's folder, `""` for the folder itself. */
|
|
37
|
+
readonly rel: string;
|
|
38
|
+
readonly place: RecordPlace;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Which quest an absolute path belongs to and what it is there, or
|
|
42
|
+
* undefined when it is not inside a quest folder.
|
|
43
|
+
*
|
|
44
|
+
* Quests sit directly under the quests root, one folder named by the
|
|
45
|
+
* quest's ID, so the first segment beneath the root decides the quest.
|
|
46
|
+
*/
|
|
47
|
+
export declare function questRecordPath(questsRoot: string, path: string): QuestRecordPath | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* What an attachment may be. Text, read as a file with no NUL byte in
|
|
50
|
+
* its first `sniffBytes`, up to `textBytes`; a raster image up to
|
|
51
|
+
* `imageBytes`. The backup keeps the same numbers.
|
|
52
|
+
*/
|
|
53
|
+
export declare const ATTACHMENT_LIMITS: {
|
|
54
|
+
readonly textBytes: number;
|
|
55
|
+
readonly imageBytes: number;
|
|
56
|
+
readonly sniffBytes: 8192;
|
|
57
|
+
};
|
|
58
|
+
/** What is known about a file that might be an attachment. */
|
|
59
|
+
export interface AttachmentFile {
|
|
60
|
+
/** The path relative to the quest's folder. */
|
|
61
|
+
readonly rel: string;
|
|
62
|
+
readonly kind: "file" | "symlink" | "directory" | "other";
|
|
63
|
+
readonly size: number;
|
|
64
|
+
/** The file's first bytes, at least `sniffBytes` of them when it has that many. */
|
|
65
|
+
readonly head: Uint8Array;
|
|
66
|
+
}
|
|
67
|
+
/** Why a file cannot be an attachment. */
|
|
68
|
+
export interface AttachmentProblem {
|
|
69
|
+
readonly reason: "not-a-file" | "in-a-checkout" | "raw-data" | "binary" | "too-large";
|
|
70
|
+
/** The same, in words a refusal can quote. */
|
|
71
|
+
readonly detail: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Why a path cannot be an attachment, as far as its name decides: a path
|
|
75
|
+
* inside a git checkout, or a raw-data type. Undefined when the name
|
|
76
|
+
* allows it, which does not yet mean the file does.
|
|
77
|
+
*/
|
|
78
|
+
export declare function attachmentNameProblem(rel: string): AttachmentProblem | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Why a file cannot be an attachment, or undefined when it can. A folder
|
|
81
|
+
* is fine, since it only holds attachments, unless it is a checkout's
|
|
82
|
+
* `.git`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function attachmentProblem(file: AttachmentFile): AttachmentProblem | undefined;
|
|
85
|
+
/** Which documents cite each attachment, and the links that lead nowhere. */
|
|
86
|
+
export interface AttachmentCitations {
|
|
87
|
+
/** Each cited attachment's quest-relative path, and who cites it. */
|
|
88
|
+
readonly cited: Map<string, string[]>;
|
|
89
|
+
/** Links into `attachments/` that name no attachment, as written. */
|
|
90
|
+
readonly broken: {
|
|
91
|
+
document: string;
|
|
92
|
+
target: string;
|
|
93
|
+
}[];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Which of a quest's documents cite each of its attachments.
|
|
97
|
+
*
|
|
98
|
+
* Documents live in the quest folder or one level down, so any mention
|
|
99
|
+
* of an attachment runs through `attachments/`, whichever way it is
|
|
100
|
+
* written: a link, an image, a reference definition, a code span, or a
|
|
101
|
+
* path in prose or a code block. A mention of a folder cites everything
|
|
102
|
+
* in it, and a glob cites the folder it starts in. A mention that names
|
|
103
|
+
* another quest's folder is that quest's business.
|
|
104
|
+
*
|
|
105
|
+
* Only a link can be broken. Prose often names a path that does not exist
|
|
106
|
+
* yet ("put the chart in attachments/cost.png"), while a link that goes
|
|
107
|
+
* nowhere is a record that lost its attachment.
|
|
108
|
+
*/
|
|
109
|
+
export declare function attachmentCitations(quest: string, documents: readonly {
|
|
110
|
+
rel: string;
|
|
111
|
+
text: string;
|
|
112
|
+
}[], attachments: readonly string[]): AttachmentCitations;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A quest's record: what in a quest folder is kept, backed up and
|
|
3
|
+
* protected, and what is not.
|
|
4
|
+
*
|
|
5
|
+
* The record is the README, the documents (an ID-named markdown file
|
|
6
|
+
* directly in a kind folder), and one shared `attachments/` folder that
|
|
7
|
+
* any document may cite. Everything else a quest makes (clones, raw
|
|
8
|
+
* data, labs, runs, builds) belongs in its workspace outside the quests
|
|
9
|
+
* folder, so anything else found inside one is a stray.
|
|
10
|
+
*
|
|
11
|
+
* The backup in the dotfiles (`quest-backup`) takes exactly this record
|
|
12
|
+
* and must work without pi, so it keeps its own copy of these rules. A
|
|
13
|
+
* change here needs the same change there.
|
|
14
|
+
*/
|
|
15
|
+
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
16
|
+
import { isId, prefixOf } from "./id.js";
|
|
17
|
+
/** The folders a quest's documents live in, one per kind. */
|
|
18
|
+
export const DOCUMENT_FOLDERS = [
|
|
19
|
+
"plans",
|
|
20
|
+
"research",
|
|
21
|
+
"briefs",
|
|
22
|
+
"reports",
|
|
23
|
+
];
|
|
24
|
+
/** The folder a quest's attachments live in. */
|
|
25
|
+
export const ATTACHMENTS_FOLDER = "attachments";
|
|
26
|
+
/**
|
|
27
|
+
* Which quest an absolute path belongs to and what it is there, or
|
|
28
|
+
* undefined when it is not inside a quest folder.
|
|
29
|
+
*
|
|
30
|
+
* Quests sit directly under the quests root, one folder named by the
|
|
31
|
+
* quest's ID, so the first segment beneath the root decides the quest.
|
|
32
|
+
*/
|
|
33
|
+
export function questRecordPath(questsRoot, path) {
|
|
34
|
+
if (!isAbsolute(path))
|
|
35
|
+
return undefined;
|
|
36
|
+
const fromRoot = relative(resolve(questsRoot), resolve(path));
|
|
37
|
+
if (!fromRoot || fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const [quest, ...rest] = fromRoot.split(sep);
|
|
41
|
+
if (quest === undefined || prefixOf(quest) !== "QEST")
|
|
42
|
+
return undefined;
|
|
43
|
+
const rel = rest.join("/");
|
|
44
|
+
return {
|
|
45
|
+
quest,
|
|
46
|
+
questDir: join(resolve(questsRoot), quest),
|
|
47
|
+
rel,
|
|
48
|
+
place: placeOf(rest),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** What a quest-relative path, given as its segments, is. */
|
|
52
|
+
function placeOf(segments) {
|
|
53
|
+
const [top, name, ...deeper] = segments;
|
|
54
|
+
if (top === undefined)
|
|
55
|
+
return "quest";
|
|
56
|
+
if (top === ATTACHMENTS_FOLDER) {
|
|
57
|
+
return name === undefined ? "folder" : "attachment";
|
|
58
|
+
}
|
|
59
|
+
if (top === "README.md" && name === undefined)
|
|
60
|
+
return "readme";
|
|
61
|
+
if (!isKindFolder(top))
|
|
62
|
+
return "stray";
|
|
63
|
+
if (name === undefined)
|
|
64
|
+
return "folder";
|
|
65
|
+
if (deeper.length > 0 || !isDocumentName(name))
|
|
66
|
+
return "stray";
|
|
67
|
+
return "document";
|
|
68
|
+
}
|
|
69
|
+
function isKindFolder(name) {
|
|
70
|
+
return DOCUMENT_FOLDERS.includes(name);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* What an attachment may be. Text, read as a file with no NUL byte in
|
|
74
|
+
* its first `sniffBytes`, up to `textBytes`; a raster image up to
|
|
75
|
+
* `imageBytes`. The backup keeps the same numbers.
|
|
76
|
+
*/
|
|
77
|
+
export const ATTACHMENT_LIMITS = {
|
|
78
|
+
textBytes: 1024 * 1024,
|
|
79
|
+
imageBytes: 5 * 1024 * 1024,
|
|
80
|
+
sniffBytes: 8192,
|
|
81
|
+
};
|
|
82
|
+
/** Raster image types, which are the one binary an attachment may be. */
|
|
83
|
+
const IMAGE_TYPES = new Set([
|
|
84
|
+
"png",
|
|
85
|
+
"jpg",
|
|
86
|
+
"jpeg",
|
|
87
|
+
"gif",
|
|
88
|
+
"webp",
|
|
89
|
+
"avif",
|
|
90
|
+
"heic",
|
|
91
|
+
"bmp",
|
|
92
|
+
"ico",
|
|
93
|
+
]);
|
|
94
|
+
/**
|
|
95
|
+
* Types that hold raw data rather than something a document cites:
|
|
96
|
+
* databases, archives, git objects, logs, traces, dumps and builds. Their
|
|
97
|
+
* home is the workspace even when they happen to be small or text.
|
|
98
|
+
*/
|
|
99
|
+
const RAW_DATA_TYPES = new Set([
|
|
100
|
+
"db",
|
|
101
|
+
"sqlite",
|
|
102
|
+
"sqlite3",
|
|
103
|
+
"zip",
|
|
104
|
+
"tar",
|
|
105
|
+
"tgz",
|
|
106
|
+
"gz",
|
|
107
|
+
"zst",
|
|
108
|
+
"xz",
|
|
109
|
+
"bz2",
|
|
110
|
+
"7z",
|
|
111
|
+
"pack",
|
|
112
|
+
"idx",
|
|
113
|
+
"jsonl",
|
|
114
|
+
"ndjson",
|
|
115
|
+
"log",
|
|
116
|
+
"trace",
|
|
117
|
+
"pcap",
|
|
118
|
+
"dump",
|
|
119
|
+
"core",
|
|
120
|
+
"heapsnapshot",
|
|
121
|
+
"cpuprofile",
|
|
122
|
+
"slab",
|
|
123
|
+
"bin",
|
|
124
|
+
"parquet",
|
|
125
|
+
"arrow",
|
|
126
|
+
"wasm",
|
|
127
|
+
"o",
|
|
128
|
+
"a",
|
|
129
|
+
"so",
|
|
130
|
+
"dylib",
|
|
131
|
+
"jar",
|
|
132
|
+
"class",
|
|
133
|
+
"pyc",
|
|
134
|
+
]);
|
|
135
|
+
/**
|
|
136
|
+
* Why a path cannot be an attachment, as far as its name decides: a path
|
|
137
|
+
* inside a git checkout, or a raw-data type. Undefined when the name
|
|
138
|
+
* allows it, which does not yet mean the file does.
|
|
139
|
+
*/
|
|
140
|
+
export function attachmentNameProblem(rel) {
|
|
141
|
+
if (rel.split("/").includes(".git")) {
|
|
142
|
+
return { reason: "in-a-checkout", detail: "part of a git checkout" };
|
|
143
|
+
}
|
|
144
|
+
const type = typeOf(rel);
|
|
145
|
+
if (type !== undefined && RAW_DATA_TYPES.has(type)) {
|
|
146
|
+
return { reason: "raw-data", detail: `a .${type} file, which is raw data` };
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Why a file cannot be an attachment, or undefined when it can. A folder
|
|
152
|
+
* is fine, since it only holds attachments, unless it is a checkout's
|
|
153
|
+
* `.git`.
|
|
154
|
+
*/
|
|
155
|
+
export function attachmentProblem(file) {
|
|
156
|
+
const named = attachmentNameProblem(file.rel);
|
|
157
|
+
if (named)
|
|
158
|
+
return named;
|
|
159
|
+
if (file.kind === "directory")
|
|
160
|
+
return undefined;
|
|
161
|
+
if (file.kind !== "file") {
|
|
162
|
+
return {
|
|
163
|
+
reason: "not-a-file",
|
|
164
|
+
detail: `a ${file.kind}, not a regular file`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const type = typeOf(file.rel);
|
|
168
|
+
if (type !== undefined && IMAGE_TYPES.has(type)) {
|
|
169
|
+
return overLimit(file.size, ATTACHMENT_LIMITS.imageBytes, "image");
|
|
170
|
+
}
|
|
171
|
+
const sniffed = file.head.subarray(0, ATTACHMENT_LIMITS.sniffBytes);
|
|
172
|
+
if (sniffed.includes(0)) {
|
|
173
|
+
return { reason: "binary", detail: "binary, neither text nor an image" };
|
|
174
|
+
}
|
|
175
|
+
return overLimit(file.size, ATTACHMENT_LIMITS.textBytes, "text");
|
|
176
|
+
}
|
|
177
|
+
/** A markdown link or image target, inline or as a reference definition. */
|
|
178
|
+
const LINK_TARGET = /\]\(\s*(<[^>\n]+>|[^)\s]+)|^ {0,3}\[[^\]\n]+\]:[ \t]*(<[^>\n]+>|\S+)/gm;
|
|
179
|
+
/** Any path-like run of text that goes through an `attachments/` folder. */
|
|
180
|
+
const MENTION = new RegExp(`[^\\s\`'"()<>\\[\\]|,;]*${ATTACHMENTS_FOLDER}/[^\\s\`'"()<>\\[\\]|,;]*`, "g");
|
|
181
|
+
const QUEST_ID = /QEST-\d{8}-[0-9A-Z]{6}/g;
|
|
182
|
+
/**
|
|
183
|
+
* Which of a quest's documents cite each of its attachments.
|
|
184
|
+
*
|
|
185
|
+
* Documents live in the quest folder or one level down, so any mention
|
|
186
|
+
* of an attachment runs through `attachments/`, whichever way it is
|
|
187
|
+
* written: a link, an image, a reference definition, a code span, or a
|
|
188
|
+
* path in prose or a code block. A mention of a folder cites everything
|
|
189
|
+
* in it, and a glob cites the folder it starts in. A mention that names
|
|
190
|
+
* another quest's folder is that quest's business.
|
|
191
|
+
*
|
|
192
|
+
* Only a link can be broken. Prose often names a path that does not exist
|
|
193
|
+
* yet ("put the chart in attachments/cost.png"), while a link that goes
|
|
194
|
+
* nowhere is a record that lost its attachment.
|
|
195
|
+
*/
|
|
196
|
+
export function attachmentCitations(quest, documents, attachments) {
|
|
197
|
+
const cited = new Map();
|
|
198
|
+
const broken = [];
|
|
199
|
+
const credit = (document, mention) => {
|
|
200
|
+
const key = attachmentKey(quest, mention);
|
|
201
|
+
if (key === undefined)
|
|
202
|
+
return true;
|
|
203
|
+
const matches = attachments.filter((attachment) => attachment === key || attachment.startsWith(`${key}/`));
|
|
204
|
+
for (const attachment of matches) {
|
|
205
|
+
const citers = cited.get(attachment) ?? [];
|
|
206
|
+
if (!citers.includes(document))
|
|
207
|
+
citers.push(document);
|
|
208
|
+
cited.set(attachment, citers);
|
|
209
|
+
}
|
|
210
|
+
return matches.length > 0;
|
|
211
|
+
};
|
|
212
|
+
for (const { rel, text } of documents) {
|
|
213
|
+
for (const match of text.matchAll(LINK_TARGET)) {
|
|
214
|
+
const written = (match[1] ?? match[2] ?? "").replace(/^<|>$/g, "");
|
|
215
|
+
const target = decoded(written.replace(/[#?].*$/, ""));
|
|
216
|
+
if (!target.includes(`${ATTACHMENTS_FOLDER}/`))
|
|
217
|
+
continue;
|
|
218
|
+
if (!credit(rel, target))
|
|
219
|
+
broken.push({ document: rel, target: written });
|
|
220
|
+
}
|
|
221
|
+
for (const [mention] of text.matchAll(MENTION))
|
|
222
|
+
credit(rel, mention);
|
|
223
|
+
}
|
|
224
|
+
return { cited, broken };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The quest-relative path a mention names under `attachments/`, or
|
|
228
|
+
* undefined when it names another quest's, the folder as a whole, or
|
|
229
|
+
* nothing that stays inside it.
|
|
230
|
+
*/
|
|
231
|
+
function attachmentKey(quest, mention) {
|
|
232
|
+
const ids = mention.match(QUEST_ID) ?? [];
|
|
233
|
+
const owner = ids.at(-1);
|
|
234
|
+
if (owner !== undefined && owner !== quest)
|
|
235
|
+
return undefined;
|
|
236
|
+
const marker = `${ATTACHMENTS_FOLDER}/`;
|
|
237
|
+
const after = owner === undefined ? 0 : mention.lastIndexOf(owner);
|
|
238
|
+
const at = mention.indexOf(marker, after);
|
|
239
|
+
if (at < 0)
|
|
240
|
+
return undefined;
|
|
241
|
+
let within = mention.slice(at + marker.length).replace(/[#?].*$/, "");
|
|
242
|
+
const glob = within.search(/[*?[{]/);
|
|
243
|
+
if (glob >= 0)
|
|
244
|
+
within = within.slice(0, within.lastIndexOf("/", glob) + 1);
|
|
245
|
+
within = within.replace(/[.:;!?*_]+$/, "");
|
|
246
|
+
const normal = posix.normalize(within).replace(/\/+$/, "");
|
|
247
|
+
if (!normal || normal === "." || normal.startsWith(".."))
|
|
248
|
+
return undefined;
|
|
249
|
+
return `${marker}${normal}`;
|
|
250
|
+
}
|
|
251
|
+
/** A link target with its percent-encoding undone, or as written when malformed. */
|
|
252
|
+
function decoded(target) {
|
|
253
|
+
try {
|
|
254
|
+
return decodeURI(target);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return target;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** A size problem when `size` is over `limit`. */
|
|
261
|
+
function overLimit(size, limit, what) {
|
|
262
|
+
if (size <= limit)
|
|
263
|
+
return undefined;
|
|
264
|
+
return {
|
|
265
|
+
reason: "too-large",
|
|
266
|
+
detail: `${mebibytes(size, 1)} MiB of ${what}, over the ${mebibytes(limit, 0)} MiB limit`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function mebibytes(bytes, digits) {
|
|
270
|
+
return (bytes / (1024 * 1024)).toFixed(digits);
|
|
271
|
+
}
|
|
272
|
+
/** A file's type, its last extension lowercased, or undefined without one. */
|
|
273
|
+
function typeOf(rel) {
|
|
274
|
+
const name = rel.split("/").at(-1) ?? "";
|
|
275
|
+
const dot = name.lastIndexOf(".");
|
|
276
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : undefined;
|
|
277
|
+
}
|
|
278
|
+
/** Whether a file name is a document's: a non-quest ID with `.md`. */
|
|
279
|
+
function isDocumentName(name) {
|
|
280
|
+
if (!name.endsWith(".md"))
|
|
281
|
+
return false;
|
|
282
|
+
const id = name.slice(0, -3);
|
|
283
|
+
return isId(id) && prefixOf(id) !== "QEST";
|
|
284
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A quest's workspace: where everything a quest makes that is not its
|
|
3
|
+
* record lives, such as clones, raw data, labs, runs and builds.
|
|
4
|
+
*
|
|
5
|
+
* It sits outside the quests folder, under a root the adapter chooses
|
|
6
|
+
* (pi's is its cache directory), in a folder named by the quest's ID, so
|
|
7
|
+
* anything outside pi can find a workspace's quest by name alone. Its
|
|
8
|
+
* `tmp/` takes the ad-hoc writes that would otherwise land in system
|
|
9
|
+
* temp, and is the one part a conclude clears. The rest outlives the
|
|
10
|
+
* quest for a while and is reclaimed by the disk guard, which also
|
|
11
|
+
* compresses a concluded quest's workspace; neither is done here, since
|
|
12
|
+
* both are slow and belong to the host.
|
|
13
|
+
*/
|
|
14
|
+
/** A quest's workspace folder and its `tmp/`. */
|
|
15
|
+
export interface QuestWorkspace {
|
|
16
|
+
readonly dir: string;
|
|
17
|
+
readonly tmp: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Where a quest's workspace is. Refuses anything that is not a quest's
|
|
21
|
+
* ID, since the folder is created and emptied by name and a name like
|
|
22
|
+
* `../x` would reach outside the root.
|
|
23
|
+
*/
|
|
24
|
+
export declare function questWorkspace(root: string, questId: string): QuestWorkspace;
|
|
25
|
+
/** A quest's `tmp/`, created on first need. */
|
|
26
|
+
export declare function ensureQuestWorkspaceTmp(root: string, questId: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Empty a quest's `tmp/` and leave the rest of its workspace. Returns
|
|
29
|
+
* whether there was one to clear. Best-effort, as reaping scratch was: a
|
|
30
|
+
* wedged file must not stop a quest from concluding.
|
|
31
|
+
*/
|
|
32
|
+
export declare function clearQuestWorkspaceTmp(root: string, questId: string): boolean;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A quest's workspace: where everything a quest makes that is not its
|
|
3
|
+
* record lives, such as clones, raw data, labs, runs and builds.
|
|
4
|
+
*
|
|
5
|
+
* It sits outside the quests folder, under a root the adapter chooses
|
|
6
|
+
* (pi's is its cache directory), in a folder named by the quest's ID, so
|
|
7
|
+
* anything outside pi can find a workspace's quest by name alone. Its
|
|
8
|
+
* `tmp/` takes the ad-hoc writes that would otherwise land in system
|
|
9
|
+
* temp, and is the one part a conclude clears. The rest outlives the
|
|
10
|
+
* quest for a while and is reclaimed by the disk guard, which also
|
|
11
|
+
* compresses a concluded quest's workspace; neither is done here, since
|
|
12
|
+
* both are slow and belong to the host.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { isId, prefixOf } from "./id.js";
|
|
17
|
+
/**
|
|
18
|
+
* Where a quest's workspace is. Refuses anything that is not a quest's
|
|
19
|
+
* ID, since the folder is created and emptied by name and a name like
|
|
20
|
+
* `../x` would reach outside the root.
|
|
21
|
+
*/
|
|
22
|
+
export function questWorkspace(root, questId) {
|
|
23
|
+
if (!isId(questId) || prefixOf(questId) !== "QEST") {
|
|
24
|
+
throw new Error(`"${questId}" is not a quest ID.`);
|
|
25
|
+
}
|
|
26
|
+
const dir = join(root, questId);
|
|
27
|
+
return { dir, tmp: join(dir, "tmp") };
|
|
28
|
+
}
|
|
29
|
+
/** A quest's `tmp/`, created on first need. */
|
|
30
|
+
export function ensureQuestWorkspaceTmp(root, questId) {
|
|
31
|
+
const { tmp } = questWorkspace(root, questId);
|
|
32
|
+
mkdirSync(tmp, { recursive: true });
|
|
33
|
+
return tmp;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Empty a quest's `tmp/` and leave the rest of its workspace. Returns
|
|
37
|
+
* whether there was one to clear. Best-effort, as reaping scratch was: a
|
|
38
|
+
* wedged file must not stop a quest from concluding.
|
|
39
|
+
*/
|
|
40
|
+
export function clearQuestWorkspaceTmp(root, questId) {
|
|
41
|
+
const { tmp } = questWorkspace(root, questId);
|
|
42
|
+
if (!existsSync(tmp))
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Left for the disk guard, which reclaims the whole workspace later.
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jitsusama/agentic-harness.core",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Pi-agnostic business logic for agentic-harness: state machines, guardian decisions, quest/TDD domain model.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
"./quest/verbs/queries": "./dist/quest/verbs/queries.js",
|
|
46
46
|
"./quest/verbs/lifecycle": "./dist/quest/verbs/lifecycle.js",
|
|
47
47
|
"./quest/verbs/tree-ops": "./dist/quest/verbs/tree-ops.js",
|
|
48
|
+
"./quest/bash-write": "./dist/internal/quest/bash-write.js",
|
|
49
|
+
"./quest/write-classifier": "./dist/internal/quest/write-classifier.js",
|
|
50
|
+
"./quest/record": "./dist/internal/quest/record.js",
|
|
51
|
+
"./quest/workspace": "./dist/internal/quest/workspace.js",
|
|
52
|
+
"./quest/record-gate": "./dist/internal/quest/record-gate.js",
|
|
53
|
+
"./quest/record-audit": "./dist/internal/quest/record-audit.js",
|
|
48
54
|
"./tree": "./dist/tree/index.js",
|
|
49
55
|
"./lsp": "./dist/lsp/index.js",
|
|
50
56
|
"./result": "./dist/result/index.js",
|