@christang/keel 5.7.0 → 5.16.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/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +4 -2
- package/bin/keel.js +180 -58
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/agents/keel-single-task-goal-claude.md +3 -2
- package/plugins/keel/agents/keel-single-task-goal-codex.md +2 -2
- package/plugins/keel/scripts/pretooluse-guard.js +65 -2
- package/plugins/keel/scripts/session-start.js +87 -0
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +26 -2
- package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +3 -3
- package/scripts/run_python.js +101 -5
- package/scripts/validate_plugin.py +9382 -5446
- package/src/core/config.js +58 -0
- package/src/core/context.js +41 -5
- package/src/core/gates.js +277 -46
- package/src/core/goal.js +13 -1
- package/src/core/guard.js +53 -0
- package/src/core/helper.js +27 -1
- package/src/core/projection.js +55 -0
- package/src/core/task-contract.js +30 -0
package/src/core/guard.js
CHANGED
|
@@ -9,8 +9,44 @@
|
|
|
9
9
|
const crypto = require("crypto");
|
|
10
10
|
const fs = require("fs");
|
|
11
11
|
const path = require("path");
|
|
12
|
+
const { spawnSync } = require("child_process");
|
|
12
13
|
const { loadTaskContract } = require("./task-contract");
|
|
13
14
|
|
|
15
|
+
// Nothing rewrites backslashes here. Git emits forward slashes on every
|
|
16
|
+
// platform, so the rewrite normalized a separator that never arrives while
|
|
17
|
+
// turning `\346` into `/346`, which is how a path declared on the first line
|
|
18
|
+
// of Touch was reported as outside Touch (issue #40).
|
|
19
|
+
//
|
|
20
|
+
// This lives here rather than in gates.js because both the task-start record
|
|
21
|
+
// and the completion comparison read it, and gates.js already requires this
|
|
22
|
+
// module. One implementation is the point: a baseline and a comparison that
|
|
23
|
+
// disagreed about what "dirty" means, or about how a rename is represented,
|
|
24
|
+
// would attribute a path nobody wrote.
|
|
25
|
+
function gitPaths(repo) {
|
|
26
|
+
const status = spawnSync(
|
|
27
|
+
"git",
|
|
28
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
29
|
+
{ cwd: repo, encoding: "utf8" }
|
|
30
|
+
);
|
|
31
|
+
if (status.error || status.status !== 0) return [];
|
|
32
|
+
// Each record is `XY <path>`, NUL-terminated. A rename or copy is followed
|
|
33
|
+
// by a second bare field holding its other endpoint — the new path first in
|
|
34
|
+
// `-z`, the reverse of the ` -> ` line format. The order is immaterial:
|
|
35
|
+
// both endpoints are attributed, so a rename whose paths are both in Touch
|
|
36
|
+
// is not a false outside-Touch failure.
|
|
37
|
+
const fields = status.stdout.split("\0").filter(Boolean);
|
|
38
|
+
const paths = [];
|
|
39
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
40
|
+
const record = fields[index];
|
|
41
|
+
paths.push(record.slice(3));
|
|
42
|
+
if (record[0] === "R" || record[0] === "C") {
|
|
43
|
+
index += 1;
|
|
44
|
+
if (index < fields.length) paths.push(fields[index]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return paths;
|
|
48
|
+
}
|
|
49
|
+
|
|
14
50
|
const MANIFEST_SCHEMA = "keel-write-guard/v1";
|
|
15
51
|
|
|
16
52
|
class GuardInputError extends Error {}
|
|
@@ -121,6 +157,18 @@ function readManifest(repo) {
|
|
|
121
157
|
) {
|
|
122
158
|
shapeErrors.push("authority must list hashed source files");
|
|
123
159
|
}
|
|
160
|
+
// Optional on purpose. A manifest written before this field existed, and one
|
|
161
|
+
// written by a Keel that omits it, are both valid; what they are not is
|
|
162
|
+
// evidence that nothing was dirty. The consumer distinguishes absent from
|
|
163
|
+
// empty, so an empty list means "nothing was dirty" and an absent one means
|
|
164
|
+
// "nobody looked".
|
|
165
|
+
if (
|
|
166
|
+
manifest.startedDirty !== undefined
|
|
167
|
+
&& (!Array.isArray(manifest.startedDirty)
|
|
168
|
+
|| manifest.startedDirty.some((item) => typeof item !== "string"))
|
|
169
|
+
) {
|
|
170
|
+
shapeErrors.push("startedDirty must be a string list when present");
|
|
171
|
+
}
|
|
124
172
|
if (shapeErrors.length > 0) {
|
|
125
173
|
return {
|
|
126
174
|
state: "invalid",
|
|
@@ -182,6 +230,9 @@ function startGuard(repo, options) {
|
|
|
182
230
|
}
|
|
183
231
|
|
|
184
232
|
const paths = authorityPaths(repo, options.change, loaded.contract);
|
|
233
|
+
// Read before the manifest is written, so the manifest is never in its own
|
|
234
|
+
// record and cannot be attributed to the task it authorizes.
|
|
235
|
+
const startedDirty = gitPaths(repo);
|
|
185
236
|
const manifest = {
|
|
186
237
|
schema: MANIFEST_SCHEMA,
|
|
187
238
|
change: options.change,
|
|
@@ -189,6 +240,7 @@ function startGuard(repo, options) {
|
|
|
189
240
|
fingerprint: loaded.contract.fingerprint,
|
|
190
241
|
touch: loaded.contract.capsule.touch,
|
|
191
242
|
authority: hashAuthority(repo, paths),
|
|
243
|
+
startedDirty,
|
|
192
244
|
};
|
|
193
245
|
fs.mkdirSync(path.join(repo, "keel"), { recursive: true });
|
|
194
246
|
fs.writeFileSync(
|
|
@@ -302,6 +354,7 @@ module.exports = {
|
|
|
302
354
|
GuardInputError,
|
|
303
355
|
MANIFEST_SCHEMA,
|
|
304
356
|
clearGuard,
|
|
357
|
+
gitPaths,
|
|
305
358
|
guardStatus,
|
|
306
359
|
readManifest,
|
|
307
360
|
renderGuard,
|
package/src/core/helper.js
CHANGED
|
@@ -56,8 +56,34 @@ function blockedBrief(target, reason, extra = {}) {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Symbolic links are resolved on both sides, because `process.cwd()` comes
|
|
60
|
+
// back already resolved while `path.resolve` never follows a link — on macOS,
|
|
61
|
+
// where `/tmp` is a link to `/private/tmp`, that made a path inside the
|
|
62
|
+
// worktree look external and let the helper write its baseline into the
|
|
63
|
+
// repository it had just promised not to touch. The baseline usually does not
|
|
64
|
+
// exist yet, so the nearest existing ancestor is what resolves. The write
|
|
65
|
+
// guard hook answers the same question and keeps its own copy of this rule,
|
|
66
|
+
// because it is a standalone script that cannot import from here.
|
|
67
|
+
function realPathOrNearest(target) {
|
|
68
|
+
let current = path.resolve(target);
|
|
69
|
+
const trailing = [];
|
|
70
|
+
for (;;) {
|
|
71
|
+
try {
|
|
72
|
+
return path.join(fs.realpathSync(current), ...trailing);
|
|
73
|
+
} catch {
|
|
74
|
+
const parent = path.dirname(current);
|
|
75
|
+
if (parent === current) return path.resolve(target);
|
|
76
|
+
trailing.unshift(path.basename(current));
|
|
77
|
+
current = parent;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
function isExternal(repo, candidate) {
|
|
60
|
-
const rel = path.relative(
|
|
83
|
+
const rel = path.relative(
|
|
84
|
+
realPathOrNearest(repo),
|
|
85
|
+
realPathOrNearest(candidate)
|
|
86
|
+
);
|
|
61
87
|
return (
|
|
62
88
|
rel === ".."
|
|
63
89
|
|| rel.startsWith(`..${path.sep}`)
|
package/src/core/projection.js
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// Keel 4.1.0 one-way native projection contract.
|
|
4
4
|
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const { readDelegationPolicy } = require("./config");
|
|
5
9
|
const { resolveContext } = require("./context");
|
|
6
10
|
const { loadTaskContract } = require("./task-contract");
|
|
7
11
|
const { probeCapabilities } = require("./capabilities");
|
|
@@ -34,6 +38,39 @@ function blocked(target, event, reason, warnings = []) {
|
|
|
34
38
|
};
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
// Both refusals are decided before a delegate starts, never inferred from what
|
|
42
|
+
// it did. An absent manifest passes every write through silently, so a delegate
|
|
43
|
+
// that wrote successfully under one proves nothing about having been checked —
|
|
44
|
+
// there is no observable difference afterwards, which is why the condition has
|
|
45
|
+
// to be answered here.
|
|
46
|
+
function delegationRefusal(repo, delegation) {
|
|
47
|
+
// The policy is read directly rather than through the capsule, because the
|
|
48
|
+
// capsule cannot express the difference this refusal turns on. A tier outside
|
|
49
|
+
// the vocabulary fails closed at the config layer and reaches the capsule as
|
|
50
|
+
// no delegation at all — identical to a repository that declared nothing. One
|
|
51
|
+
// of those should proceed silently and the other must be reported, so the
|
|
52
|
+
// unresolved declaration has to be seen where it still exists.
|
|
53
|
+
const { unknown, accepted } = readDelegationPolicy(repo);
|
|
54
|
+
if (unknown.length > 0) {
|
|
55
|
+
return (
|
|
56
|
+
`Delegation declares tier "${unknown.join(", ")}", which this target `
|
|
57
|
+
+ `does not provide. Accepted: ${accepted.join(", ")}. Keel refuses `
|
|
58
|
+
+ "rather than substituting a tier, because work would otherwise run at "
|
|
59
|
+
+ "a capability nobody declared while reporting success."
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (!delegation) return null;
|
|
63
|
+
if (!fs.existsSync(path.join(repo, "keel", "guard.json"))) {
|
|
64
|
+
return (
|
|
65
|
+
"Delegation requires an active write guard, and keel/guard.json is "
|
|
66
|
+
+ "absent. Without it every write passes through unchecked and looks "
|
|
67
|
+
+ "identical to a write the guard allowed. Run `keel gate task-start` "
|
|
68
|
+
+ "for the selected task, then delegate."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
37
74
|
function capabilityKey(event) {
|
|
38
75
|
if (event === "startup") return "continuity.start";
|
|
39
76
|
if (["resume", "compaction"].includes(event)) return "continuity.reinject";
|
|
@@ -157,6 +194,24 @@ function projectRuntime(repo, options) {
|
|
|
157
194
|
if (event === "subagent-stop") {
|
|
158
195
|
projection.returnAuthority = "report-and-evidence-only";
|
|
159
196
|
}
|
|
197
|
+
// Delegation extends the brief Keel already publishes rather than adding a
|
|
198
|
+
// carrier beside the host's own agent interface. The host spawns; this is the
|
|
199
|
+
// one-way view of OpenSpec it is handed.
|
|
200
|
+
if (event === "subagent-start") {
|
|
201
|
+
const refusal = delegationRefusal(repo, capsule.delegation);
|
|
202
|
+
if (refusal) return blocked(options.target, event, refusal, warnings);
|
|
203
|
+
}
|
|
204
|
+
if (event === "subagent-start" && capsule.delegation) {
|
|
205
|
+
projection.delegation = {
|
|
206
|
+
tier: capsule.delegation.tier,
|
|
207
|
+
source: capsule.delegation.source,
|
|
208
|
+
writeBoundary: capsule.touch,
|
|
209
|
+
note:
|
|
210
|
+
"Keel carries the declared tier and does not select a model; the "
|
|
211
|
+
+ "target resolves it. Keel cannot observe which model executed, so "
|
|
212
|
+
+ "the tier is what is recorded and never a claim about what ran.",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
160
215
|
|
|
161
216
|
return {
|
|
162
217
|
schemaVersion: 1,
|
|
@@ -6,6 +6,7 @@ const path = require("path");
|
|
|
6
6
|
|
|
7
7
|
const {
|
|
8
8
|
CONFIG_RELATIVE_PATH,
|
|
9
|
+
readDelegationPolicy,
|
|
9
10
|
readStandingAuthorization,
|
|
10
11
|
} = require("./config");
|
|
11
12
|
|
|
@@ -866,6 +867,24 @@ function compileTaskContract(repo, change, task) {
|
|
|
866
867
|
if (!autonomy.some((item) => /^Pre-authorized fallback:/i.test(item))) {
|
|
867
868
|
autonomy.push("Pre-authorized fallback: none");
|
|
868
869
|
}
|
|
870
|
+
// Who runs this task, resolved exactly as the autonomy boundary above is: the
|
|
871
|
+
// task keeps whatever it authored, the repository declaration supplies only
|
|
872
|
+
// what the task left silent, and the entry names its source. A declaration
|
|
873
|
+
// that could overwrite an authored tier would make the capsule unreadable on
|
|
874
|
+
// its own — you could not tell what this task decided from what the file did.
|
|
875
|
+
const authoredTier = normalizeText(field(task, "Delegation")).toLowerCase();
|
|
876
|
+
let delegation = { tier: null, source: null };
|
|
877
|
+
if (authoredTier) {
|
|
878
|
+
delegation = { tier: authoredTier, source: "task" };
|
|
879
|
+
} else {
|
|
880
|
+
const { tier } = readDelegationPolicy(repo);
|
|
881
|
+
if (tier) {
|
|
882
|
+
delegation = {
|
|
883
|
+
tier,
|
|
884
|
+
source: CONFIG_RELATIVE_PATH.split(path.sep).join("/"),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
}
|
|
869
888
|
// A question is unresolved authority when it is the subject of its Covers
|
|
870
889
|
// entry. Scanning the whole field also matched a resolved question named as
|
|
871
890
|
// supporting detail beside the fact that closed it, and the only fix
|
|
@@ -940,7 +959,18 @@ function compileTaskContract(repo, change, task) {
|
|
|
940
959
|
couplingMode === "required" ? candidateBoundary : [],
|
|
941
960
|
designContract: coupledContract,
|
|
942
961
|
},
|
|
962
|
+
// A helper and a delegate are different roles. A helper is never a second
|
|
963
|
+
// writer and this stays true whatever the repository declares; delegation
|
|
964
|
+
// is a separate entry beside it, never a helper with the guard removed.
|
|
943
965
|
helperAuthority: "read-only-evidence-only",
|
|
966
|
+
// Present only when a tier actually resolved. An unconditional field would
|
|
967
|
+
// change the compiled capsule for every task everywhere, moving every
|
|
968
|
+
// recorded anchor and drifting every live change in every consumer repo on
|
|
969
|
+
// upgrade — for repositories that declared nothing and asked for nothing.
|
|
970
|
+
// Omission keeps this release's invariant: no behavior change without a
|
|
971
|
+
// declaration. A repository that does declare gets a different capsule,
|
|
972
|
+
// which is honest, because its execution genuinely differs.
|
|
973
|
+
...(delegation.tier ? { delegation } : {}),
|
|
944
974
|
prohibitions: [
|
|
945
975
|
"must not change Acceptance",
|
|
946
976
|
// repo-action is the one mode whose authorized effect is the repository
|