pi-canon 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -50
- package/extensions/canon.ts +17 -2
- package/extensions/index.js +9 -3
- package/extensions/lib/lint.ts +47 -5
- package/extensions/lib/schema.ts +251 -0
- package/extensions/lib/store.ts +21 -4
- package/extensions/lib/surfacing.ts +60 -3
- package/extensions/lib/tool.ts +202 -72
- package/extensions/settings.ts +328 -0
- package/package.json +4 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/* Per-store article schema: schema.json at the store root, a data contract rather
|
|
2
|
+
than buried code. The file is created with these defaults made explicit the first
|
|
3
|
+
time the store persists anything, so its owner can see it, edit it per project,
|
|
4
|
+
and point other tools at the same contract this package enforces.
|
|
5
|
+
|
|
6
|
+
Enforcement is asymmetric on purpose. A rule marked required rejects the write
|
|
7
|
+
that violates it, because a missing required field is exactly the class of miss
|
|
8
|
+
that soft advice has demonstrably failed to prevent; every other rule warns, on
|
|
9
|
+
write and on read, so an agent reading a noncompliant article learns it can heal
|
|
10
|
+
what it is holding. A required violation the current write did not touch also
|
|
11
|
+
only warns: a capsule-only update must not be held hostage to a legacy body. */
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { Article } from "./store.ts";
|
|
16
|
+
|
|
17
|
+
export const SCHEMA_FILE = "schema.json";
|
|
18
|
+
export const SCHEMA_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export interface SchemaRule {
|
|
21
|
+
required?: boolean;
|
|
22
|
+
min_chars?: number;
|
|
23
|
+
max_chars?: number;
|
|
24
|
+
hint?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Relations rules are about the reference graph, not any one field. The store
|
|
28
|
+
declares them once here; each tool enforces the ones it can see. This package
|
|
29
|
+
sees an article's own outgoing [[references]] at the write boundary, so it
|
|
30
|
+
enforces refs; orphan and children need the whole graph and are enforced by
|
|
31
|
+
graph-reading tools (canon-atlas) from this same file. */
|
|
32
|
+
export interface RefsRule {
|
|
33
|
+
required?: boolean;
|
|
34
|
+
min_count?: number;
|
|
35
|
+
hint?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface OrphanRule {
|
|
38
|
+
warn?: boolean;
|
|
39
|
+
hint?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ChildrenRule {
|
|
42
|
+
listed?: boolean;
|
|
43
|
+
hint?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface CanonRelations {
|
|
46
|
+
refs?: RefsRule;
|
|
47
|
+
orphan?: OrphanRule;
|
|
48
|
+
children?: ChildrenRule;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CanonSchema {
|
|
52
|
+
capsule?: SchemaRule;
|
|
53
|
+
title?: SchemaRule;
|
|
54
|
+
body?: SchemaRule;
|
|
55
|
+
relations?: CanonRelations;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FIELD_NAMES = ["capsule", "title", "body"] as const;
|
|
59
|
+
const RULE_KEYS = new Set(["required", "min_chars", "max_chars", "hint"]);
|
|
60
|
+
const RELATION_KEYS: Record<string, Set<string>> = {
|
|
61
|
+
refs: new Set(["required", "min_count", "hint"]),
|
|
62
|
+
orphan: new Set(["warn", "hint"]),
|
|
63
|
+
children: new Set(["listed", "hint"]),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/* The shipped defaults, written into the file verbatim. Nothing is required and the
|
|
67
|
+
caps mirror what the advisory lint has always said, so a store that never edits
|
|
68
|
+
this file behaves as it always did, just with the contract visible on disk. */
|
|
69
|
+
const DEFAULT_FILE = `{
|
|
70
|
+
"schema_version": 1,
|
|
71
|
+
"about": "Article schema for this canon store. pi-canon enforces it at the tool boundary: a rule marked required rejects a write that violates it (judged on what the write touches, and on everything when the article is first created); every other rule warns on write and is reported on read, so agents can heal what they are holding. Other tools reading this store can enforce the same contract from this file. Fields: capsule (the front matter line surfaced on touch), title (the body's leading # heading), body. Rule keys: required, min_chars, max_chars, hint. Relations rules live under relations: refs (required, min_count) over an article's own outgoing references, orphan (warn) and children (listed) over the whole graph; each tool enforces the rules it can see, so refs holds here and the graph rules hold in graph-reading tools like canon-atlas. Delete a rule to drop it; delete this file to disable schema checks.",
|
|
72
|
+
"article": {
|
|
73
|
+
"capsule": { "required": false, "max_chars": 1000, "hint": "One dense line of current truth; surfacing injects it when the asset is touched." },
|
|
74
|
+
"title": { "required": false, "hint": "Start the body with a # heading naming the asset." },
|
|
75
|
+
"body": { "max_chars": 20000, "hint": "Past this, go hierarchical: keep this article as the summary and router, and move detail into children at addresses under it." }
|
|
76
|
+
},
|
|
77
|
+
"relations": {
|
|
78
|
+
"refs": { "required": false, "hint": "Outgoing [[references]] authored by this article. required rejects a write whose body cites nothing; min_count warns under a floor." },
|
|
79
|
+
"orphan": { "warn": false, "hint": "Warn when no other article references this one. Needs the whole graph, so graph-reading tools enforce it." },
|
|
80
|
+
"children": { "listed": false, "hint": "Warn when an article does not reference each direct child under its address. Enforced by graph-reading tools." }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
/* Created only when absent, so an edited or deleted file is never fought over. */
|
|
86
|
+
export function ensureSchemaFile(root: string): void {
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(join(root, SCHEMA_FILE), DEFAULT_FILE, { flag: "wx" });
|
|
89
|
+
} catch {
|
|
90
|
+
/* Exists already, or the root is unwritable; either way not this call's problem. */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* The schema as declared, plus every problem with the declaration itself. A malformed
|
|
95
|
+
file fails open and loud: rules stop being enforced, and the caller says so, because
|
|
96
|
+
a schema the owner believes is enforced while a typo disabled it is the worst state. */
|
|
97
|
+
export function loadSchema(root: string): { schema: CanonSchema | undefined; problems: string[] } {
|
|
98
|
+
const file = join(root, SCHEMA_FILE);
|
|
99
|
+
if (!existsSync(file)) return { schema: undefined, problems: [] };
|
|
100
|
+
let raw: unknown;
|
|
101
|
+
try {
|
|
102
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return {
|
|
105
|
+
schema: undefined,
|
|
106
|
+
problems: [`${SCHEMA_FILE} is not valid JSON (${(error as Error).message}); its rules are not being enforced.`],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
110
|
+
return { schema: undefined, problems: [`${SCHEMA_FILE} must hold a JSON object; its rules are not being enforced.`] };
|
|
111
|
+
}
|
|
112
|
+
const problems: string[] = [];
|
|
113
|
+
/* A missing article block is an empty one, not an early exit: a schema may
|
|
114
|
+
carry only relations rules. */
|
|
115
|
+
const declared = (raw as Record<string, unknown>).article ?? {};
|
|
116
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
|
|
117
|
+
return { schema: undefined, problems: [`${SCHEMA_FILE}: "article" must be an object; its rules are not being enforced.`] };
|
|
118
|
+
}
|
|
119
|
+
const schema: CanonSchema = {};
|
|
120
|
+
for (const [name, value] of Object.entries(declared as Record<string, unknown>)) {
|
|
121
|
+
if (!(FIELD_NAMES as readonly string[]).includes(name)) {
|
|
122
|
+
problems.push(`${SCHEMA_FILE}: unknown article field "${name}" is ignored (fields: ${FIELD_NAMES.join(", ")}).`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
126
|
+
problems.push(`${SCHEMA_FILE}: rule for "${name}" must be an object and is ignored.`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const rule: SchemaRule = {};
|
|
130
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
131
|
+
if (!RULE_KEYS.has(key)) {
|
|
132
|
+
problems.push(`${SCHEMA_FILE}: unknown rule key "${name}.${key}" is ignored (keys: required, min_chars, max_chars, hint).`);
|
|
133
|
+
} else if (key === "required" && typeof val === "boolean") rule.required = val;
|
|
134
|
+
else if ((key === "min_chars" || key === "max_chars") && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
|
|
135
|
+
else if (key === "hint" && typeof val === "string") rule.hint = val;
|
|
136
|
+
else problems.push(`${SCHEMA_FILE}: "${name}.${key}" has the wrong type and is ignored.`);
|
|
137
|
+
}
|
|
138
|
+
schema[name as "capsule" | "title" | "body"] = rule;
|
|
139
|
+
}
|
|
140
|
+
const rel = (raw as Record<string, unknown>).relations;
|
|
141
|
+
if (rel !== undefined) {
|
|
142
|
+
if (typeof rel !== "object" || rel === null || Array.isArray(rel)) {
|
|
143
|
+
problems.push(`${SCHEMA_FILE}: "relations" must be an object and is ignored.`);
|
|
144
|
+
} else {
|
|
145
|
+
const relations: CanonRelations = {};
|
|
146
|
+
for (const [name, value] of Object.entries(rel as Record<string, unknown>)) {
|
|
147
|
+
const keys = RELATION_KEYS[name];
|
|
148
|
+
if (!keys) {
|
|
149
|
+
problems.push(`${SCHEMA_FILE}: unknown relations field "${name}" is ignored (fields: refs, orphan, children).`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
153
|
+
problems.push(`${SCHEMA_FILE}: rule for "relations.${name}" must be an object and is ignored.`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const rule: Record<string, boolean | number | string> = {};
|
|
157
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
158
|
+
if (!keys.has(key)) {
|
|
159
|
+
problems.push(`${SCHEMA_FILE}: unknown rule key "relations.${name}.${key}" is ignored (keys: ${[...keys].join(", ")}).`);
|
|
160
|
+
} else if ((key === "required" || key === "warn" || key === "listed") && typeof val === "boolean") rule[key] = val;
|
|
161
|
+
else if (key === "min_count" && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
|
|
162
|
+
else if (key === "hint" && typeof val === "string") rule[key] = val;
|
|
163
|
+
else problems.push(`${SCHEMA_FILE}: "relations.${name}.${key}" has the wrong type and is ignored.`);
|
|
164
|
+
}
|
|
165
|
+
relations[name as keyof CanonRelations] = rule;
|
|
166
|
+
}
|
|
167
|
+
schema.relations = relations;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { schema, problems };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* The outgoing references an article authors: [[wikilink]] targets in the body,
|
|
174
|
+
fenced and inline code stripped (examples are not citations), deduplicated
|
|
175
|
+
case-insensitively. The write boundary can always see these, whatever else it
|
|
176
|
+
cannot see of the graph. */
|
|
177
|
+
export function outgoingOf(body: string): string[] {
|
|
178
|
+
const stripped = body.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
|
|
179
|
+
const out = new Set<string>();
|
|
180
|
+
for (const m of stripped.matchAll(/\[\[([^\]|#\n]+)(?:[|#][^\]\n]*)?\]\]/g)) {
|
|
181
|
+
const target = m[1].trim().toLowerCase();
|
|
182
|
+
if (target) out.add(target);
|
|
183
|
+
}
|
|
184
|
+
return [...out].sort();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* The title is the body's leading # heading; the write interface has no separate
|
|
188
|
+
title field on purpose, so the rule checks the one place a title can live. */
|
|
189
|
+
export function titleOf(body: string): string | undefined {
|
|
190
|
+
const first = body.split(/\r?\n/).find((line) => line.trim() !== "");
|
|
191
|
+
const heading = first === undefined ? undefined : /^#\s+(.+)$/.exec(first.trim());
|
|
192
|
+
return heading ? heading[1].trim() : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface Touched {
|
|
196
|
+
capsule: boolean;
|
|
197
|
+
body: boolean;
|
|
198
|
+
refs: boolean;
|
|
199
|
+
created: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* No write in flight: every violation is reportable but none can reject. */
|
|
203
|
+
export const READ_ONLY: Touched = { capsule: false, body: false, refs: false, created: false };
|
|
204
|
+
|
|
205
|
+
export interface Verdict {
|
|
206
|
+
rejections: string[];
|
|
207
|
+
warnings: string[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function checkArticle(article: Article, schema: CanonSchema, touched: Touched): Verdict {
|
|
211
|
+
const verdict: Verdict = { rejections: [], warnings: [] };
|
|
212
|
+
const fields: { name: keyof CanonSchema; value: string | undefined; missing: string; carrier: "capsule" | "body" }[] = [
|
|
213
|
+
{ name: "capsule", value: article.capsule || undefined, missing: "required and empty", carrier: "capsule" },
|
|
214
|
+
{ name: "title", value: titleOf(article.body), missing: "required and the body has no leading # heading", carrier: "body" },
|
|
215
|
+
{ name: "body", value: article.body || undefined, missing: "required and empty", carrier: "body" },
|
|
216
|
+
];
|
|
217
|
+
for (const field of fields) {
|
|
218
|
+
const rule = schema[field.name];
|
|
219
|
+
if (!rule) continue;
|
|
220
|
+
const hint = rule.hint ? ` ${rule.hint}` : "";
|
|
221
|
+
if (rule.required && field.value === undefined) {
|
|
222
|
+
const message = `${field.name}: ${field.missing}.${hint}`;
|
|
223
|
+
if (touched.created || touched[field.carrier]) verdict.rejections.push(message);
|
|
224
|
+
else verdict.warnings.push(message);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (field.value === undefined) continue;
|
|
228
|
+
if (rule.min_chars !== undefined && field.value.length < rule.min_chars) {
|
|
229
|
+
verdict.warnings.push(`${field.name}: ${field.value.length} chars (min ${rule.min_chars}).${hint}`);
|
|
230
|
+
}
|
|
231
|
+
if (rule.max_chars !== undefined && field.value.length > rule.max_chars) {
|
|
232
|
+
verdict.warnings.push(`${field.name}: ${field.value.length} chars (max ${rule.max_chars}).${hint}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/* The one relations rule this boundary can see whole: the article's own
|
|
236
|
+
citations. required follows the same asymmetry as the fields, rejecting only
|
|
237
|
+
the write that changed the reference set or created the article. */
|
|
238
|
+
const refsRule = schema.relations && schema.relations.refs;
|
|
239
|
+
if (refsRule) {
|
|
240
|
+
const cited = outgoingOf(article.body).length;
|
|
241
|
+
const hint = refsRule.hint ? ` ${refsRule.hint}` : "";
|
|
242
|
+
if (refsRule.required && cited === 0) {
|
|
243
|
+
const message = `refs: required and the body references nothing.${hint}`;
|
|
244
|
+
if (touched.created || touched.refs) verdict.rejections.push(message);
|
|
245
|
+
else verdict.warnings.push(message);
|
|
246
|
+
} else if (refsRule.min_count !== undefined && cited < refsRule.min_count) {
|
|
247
|
+
verdict.warnings.push(`refs: ${cited} outgoing (min ${refsRule.min_count}).${hint}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return verdict;
|
|
251
|
+
}
|
package/extensions/lib/store.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
|
+
import { ensureSchemaFile } from "./schema.ts";
|
|
9
10
|
|
|
10
11
|
export interface Article {
|
|
11
12
|
path: string;
|
|
@@ -235,7 +236,10 @@ export class CanonStore {
|
|
|
235
236
|
return parts.sort().join("|");
|
|
236
237
|
}
|
|
237
238
|
|
|
238
|
-
|
|
239
|
+
/* The article this write would store, without storing it. Public so the tool can
|
|
240
|
+
hold a would-be article against the store's schema and reject before anything
|
|
241
|
+
touches disk; write() persists exactly what compose() returns. */
|
|
242
|
+
compose(path: string, fields: { capsule?: string; body?: string; scope?: string }): Article {
|
|
239
243
|
path = contain(path);
|
|
240
244
|
const prior = this.read(path);
|
|
241
245
|
/* Agents sometimes paste a whole file as the body, front matter included; stored
|
|
@@ -246,7 +250,7 @@ export class CanonStore {
|
|
|
246
250
|
if (block && block[1].split(/\r?\n/).every((line) => /^[\w-]+:\s|^\s*$/.test(line))) {
|
|
247
251
|
body = body.slice(block[0].length).trimStart();
|
|
248
252
|
}
|
|
249
|
-
|
|
253
|
+
return {
|
|
250
254
|
path,
|
|
251
255
|
capsule: (fields.capsule ?? prior?.capsule ?? "").replace(/\s*\n\s*/g, " ").trim(),
|
|
252
256
|
updated: today(),
|
|
@@ -254,8 +258,13 @@ export class CanonStore {
|
|
|
254
258
|
extra: prior?.extra ?? [],
|
|
255
259
|
body,
|
|
256
260
|
};
|
|
257
|
-
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
write(path: string, fields: { capsule?: string; body?: string; scope?: string }): Article {
|
|
264
|
+
const article = this.compose(path, fields);
|
|
265
|
+
const file = this.fileFor(article.path);
|
|
258
266
|
mkdirSync(dirname(file), { recursive: true });
|
|
267
|
+
ensureSchemaFile(this.root);
|
|
259
268
|
writeFileSync(file, serialize(article));
|
|
260
269
|
return article;
|
|
261
270
|
}
|
|
@@ -263,10 +272,16 @@ export class CanonStore {
|
|
|
263
272
|
/* Journal entries are immutable: a fresh dated file per entry, wx so nothing is
|
|
264
273
|
ever overwritten. EEXIST is the retry signal, so concurrent writers each land
|
|
265
274
|
on their own file instead of one losing its entry. */
|
|
266
|
-
journal(entry: {
|
|
275
|
+
journal(entry: {
|
|
276
|
+
body: string;
|
|
277
|
+
slug?: string;
|
|
278
|
+
subject?: string[];
|
|
279
|
+
provenance?: { harness: string; sessionId?: string };
|
|
280
|
+
}): string {
|
|
267
281
|
const slug =
|
|
268
282
|
(entry.slug ?? "entry").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "entry";
|
|
269
283
|
mkdirSync(this.journalDir, { recursive: true });
|
|
284
|
+
ensureSchemaFile(this.root);
|
|
270
285
|
/* An explicit instant, because the filename cannot carry one. Entries are named by
|
|
271
286
|
date plus slug with -2, -3 for collisions, and sorting those lexicographically puts
|
|
272
287
|
-10 before -2 and the unsuffixed name after every suffixed one, so "the newest
|
|
@@ -277,6 +292,8 @@ export class CanonStore {
|
|
|
277
292
|
const front = [
|
|
278
293
|
entry.subject?.length ? `subject: [${entry.subject.map(scalar).join(", ")}]` : "",
|
|
279
294
|
`logged: ${stamp}`,
|
|
295
|
+
entry.provenance?.harness ? `harness: ${scalar(entry.provenance.harness)}` : "",
|
|
296
|
+
entry.provenance?.sessionId ? `session: ${scalar(entry.provenance.sessionId)}` : "",
|
|
280
297
|
].filter(Boolean).join("\n");
|
|
281
298
|
const text = `---\n${front}\n---\n${entry.body.trimEnd()}\n`;
|
|
282
299
|
for (let n = 1; ; n += 1) {
|
|
@@ -30,6 +30,52 @@ function trace(kind: string, data: Record<string, unknown>): void {
|
|
|
30
30
|
|
|
31
31
|
const PATHLIKE = /(?:^|[\s"'`=:,([{])(\/?[\w.@-]+(?:\/[\w.@-]+)+)/g;
|
|
32
32
|
|
|
33
|
+
/* A touch says what knowledge should surface. It does not by itself say that current
|
|
34
|
+
truth changed. The write-after reminder needs positive evidence of a modifying tool,
|
|
35
|
+
otherwise Read, Grep, and inspection-only shell calls turn an optional maintenance
|
|
36
|
+
prompt into a false stop. This list stays deliberately small: an unknown tool may
|
|
37
|
+
surface an article, but it cannot create an update obligation merely by naming a path. */
|
|
38
|
+
const MUTATING_TOOL_SUFFIXES = [
|
|
39
|
+
"applypatch",
|
|
40
|
+
"write",
|
|
41
|
+
"writefile",
|
|
42
|
+
"edit",
|
|
43
|
+
"editfile",
|
|
44
|
+
"multiedit",
|
|
45
|
+
"notebookedit",
|
|
46
|
+
"createfile",
|
|
47
|
+
"deletefile",
|
|
48
|
+
"movefile",
|
|
49
|
+
"renamefile",
|
|
50
|
+
"replaceinfile",
|
|
51
|
+
"strreplaceeditor",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const SHELL_TOOL_SUFFIXES = ["bash", "shell", "exec", "execcommand", "runcommand", "terminal"];
|
|
55
|
+
|
|
56
|
+
function stringsIn(value: unknown): string[] {
|
|
57
|
+
if (typeof value === "string") return [value];
|
|
58
|
+
if (Array.isArray(value)) return value.flatMap(stringsIn);
|
|
59
|
+
if (!value || typeof value !== "object") return [];
|
|
60
|
+
return Object.values(value).flatMap(stringsIn);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function changesAssets(toolName: unknown, input: unknown): boolean {
|
|
64
|
+
if (typeof toolName !== "string" || !toolName) return false;
|
|
65
|
+
const compact = toolName.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
66
|
+
if (MUTATING_TOOL_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return true;
|
|
67
|
+
if (!SHELL_TOOL_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return false;
|
|
68
|
+
|
|
69
|
+
const command = stringsIn(input).join("\n");
|
|
70
|
+
if (/\btools\.apply_patch\s*\(/.test(command)) return true;
|
|
71
|
+
if (/(?:^|[\n;&|]\s*)(?:sudo\s+)?(?:[\w.-]+\/)*(?:apply_patch|cp|mv|rm|mkdir|rmdir|touch|chmod|chown|ln|install|truncate|dd|patch)\b/m.test(command)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (/\bsed\s+(?:-[a-z]*i[a-z]*\b|--in-place(?:=|\b))/i.test(command)) return true;
|
|
75
|
+
if (/\bperl\s+-[a-z]*pi[a-z]*\b/i.test(command)) return true;
|
|
76
|
+
return /\bgit\s+(?:apply|checkout|restore|reset|clean|mv|rm)\b/.test(command);
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
/* Presence marks -----------------------------------------------------------------
|
|
34
80
|
|
|
35
81
|
"Seen" used to mean "we sent it once", which is only the same thing as "the agent
|
|
@@ -510,6 +556,18 @@ export class Surfacer {
|
|
|
510
556
|
}
|
|
511
557
|
}
|
|
512
558
|
|
|
559
|
+
/* Record a successful modifying call separately from a touch. A read still stages the
|
|
560
|
+
governing article, but it creates no update obligation. */
|
|
561
|
+
markChanged(assets: string[]): void {
|
|
562
|
+
for (const asset of assets) {
|
|
563
|
+
const { mount, absolute } = this.locate(asset);
|
|
564
|
+
const article = mount.store.resolve(absolute, mount.dir);
|
|
565
|
+
if (!article) continue;
|
|
566
|
+
const key = mount.name ? `${mount.name}:${article.path}` : article.path;
|
|
567
|
+
this.pendingUpdates.add(key);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
513
571
|
/* Stage each newly touched governing article. Nothing is sent or spent here. */
|
|
514
572
|
collect(assets: string[]): void {
|
|
515
573
|
for (const asset of assets) {
|
|
@@ -517,7 +575,6 @@ export class Surfacer {
|
|
|
517
575
|
const article = mount.store.resolve(absolute, mount.dir);
|
|
518
576
|
if (!article) continue;
|
|
519
577
|
const key = mount.name ? `${mount.name}:${article.path}` : article.path;
|
|
520
|
-
this.pendingUpdates.add(key);
|
|
521
578
|
if (this.seen.has(key) || this.staged.has(key)) continue;
|
|
522
579
|
const stamp = article.updated ? ` (updated ${article.updated})` : "";
|
|
523
580
|
this.staged.set(key, { capsule: article.capsule, stamp, asset });
|
|
@@ -607,8 +664,8 @@ export class Surfacer {
|
|
|
607
664
|
trace("delivery-undone", {});
|
|
608
665
|
}
|
|
609
666
|
|
|
610
|
-
/* The write-after half of the doctrine: every governing article
|
|
611
|
-
last update draws one reminder, then the slate clears
|
|
667
|
+
/* The write-after half of the doctrine: every governing article named by a successful
|
|
668
|
+
modifying call since its last update draws one reminder, then the slate clears. */
|
|
612
669
|
settleNudge(): string | undefined {
|
|
613
670
|
const stale = [...this.pendingUpdates];
|
|
614
671
|
this.lastNudge = stale;
|