@christang/keel 5.6.0 → 5.14.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 +41 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +4 -2
- package/bin/keel.js +219 -59
- 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-align-expectations/SKILL.md +21 -0
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +25 -1
- 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 +7762 -3848
- package/src/core/config.js +128 -8
- package/src/core/context.js +41 -5
- package/src/core/gates.js +236 -35
- package/src/core/goal.js +13 -1
- package/src/core/helper.js +27 -1
- package/src/core/projection.js +55 -0
- package/src/core/task-contract.js +30 -0
package/README.md
CHANGED
|
@@ -192,6 +192,43 @@ gates, evidence, review, and the write guard are untouched by anything in the st
|
|
|
192
192
|
start line reports the store's size and freshness only — precedent bodies load when a decision is
|
|
193
193
|
actually being made.
|
|
194
194
|
|
|
195
|
+
### Unattended runs
|
|
196
|
+
|
|
197
|
+
The last thing a loop needs is permission to *start*. Declare which issues may begin work without
|
|
198
|
+
being asked about:
|
|
199
|
+
|
|
200
|
+
```yaml
|
|
201
|
+
triage: # issue labels that admit work; absent means nothing does
|
|
202
|
+
- auto
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
gh issue view 42 --json labels --jq '[.labels[].name]|join(",")' | xargs keel triage --labels
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Keel never fetches the issue.** You pass what `gh` returned, and the evaluation stays local,
|
|
210
|
+
offline and deterministic — the same properties that make every other Keel answer worth trusting.
|
|
211
|
+
|
|
212
|
+
A **label** is the unit on purpose. A person applies one to one issue, so the policy admits a class
|
|
213
|
+
you curate one issue at a time — not a guess about which issues look easy, which is exactly the
|
|
214
|
+
judgement that should not be automated. Keel cannot check that a human applied the label; if your
|
|
215
|
+
automation can label issues, this declaration is wider than it looks.
|
|
216
|
+
|
|
217
|
+
**Admission answers "may this begin" and nothing after it.** Alignment still escalates every
|
|
218
|
+
material choice, every gate still runs, and the write guard still binds. In particular:
|
|
219
|
+
|
|
220
|
+
- An unattended run **may** triage, author, implement, verify, push where `authorize:` permits, and
|
|
221
|
+
**open a pull request**.
|
|
222
|
+
- It **may not merge**. Merging is where an unreviewed decision becomes your project's history, and
|
|
223
|
+
no declaration in Keel authorizes one.
|
|
224
|
+
- Admission comes from this declaration and **never from a precedent**, however much triage history
|
|
225
|
+
the store accumulates — whether an issue becomes work is a decision that stays yours to delegate
|
|
226
|
+
explicitly.
|
|
227
|
+
|
|
228
|
+
**Keel schedules nothing.** `/loop`, cron, and CI triggers are your runtime's; Keel's part is making
|
|
229
|
+
each step decidable with authority. And a run that stops at a real decision has ended the way it was
|
|
230
|
+
designed to — resist widening the policy until it stops happening.
|
|
231
|
+
|
|
195
232
|
### Full vs Lite
|
|
196
233
|
|
|
197
234
|
Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
|
|
@@ -286,6 +323,10 @@ keel guard clear --json
|
|
|
286
323
|
keel lenses list
|
|
287
324
|
keel lenses add <name> [--force]
|
|
288
325
|
|
|
326
|
+
# Unattended triage — may this issue start work without asking?
|
|
327
|
+
# Keel never fetches the issue; pass what gh returned.
|
|
328
|
+
keel triage --labels <l1,l2> [--json]
|
|
329
|
+
|
|
289
330
|
# Install / maintenance
|
|
290
331
|
keel --init | --install | --check | --doctor | --uninstall [--target <t>] [--dry-run]
|
|
291
332
|
keel --update [--dry-run]
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
Record only task-specific authority. Omitted fields inherit versioned
|
|
3
3
|
defaults: Owner is the current Keel agent, Mode is implementation, Read
|
|
4
4
|
is the change proposal/design/specs/tasks plus discovered repository
|
|
5
|
-
context, Acceptance derives from Covers, Coupling defaults to none,
|
|
6
|
-
helpers stay read-only/evidence-only
|
|
5
|
+
context, Acceptance derives from Covers, Coupling defaults to none,
|
|
6
|
+
helpers stay read-only/evidence-only, and delegation defaults to none —
|
|
7
|
+
declare `Delegation: <tier>` only to override what `keel/config.yaml`
|
|
8
|
+
supplies. Autonomy defaults to hard-stop, and
|
|
7
9
|
commit, push, sync, archive, and cross-task continuation stay unauthorized,
|
|
8
10
|
EXCEPT where `keel/config.yaml` standing-authorizes an action: a task that
|
|
9
11
|
authors no `Autonomy boundary:` inherits that declaration, and the capsule
|
package/bin/keel.js
CHANGED
|
@@ -48,6 +48,8 @@ const {
|
|
|
48
48
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
49
49
|
readPrecedentStore,
|
|
50
50
|
readStandingAuthorization,
|
|
51
|
+
readTriagePolicy,
|
|
52
|
+
triageIssue,
|
|
51
53
|
} = require("../src/core/config");
|
|
52
54
|
|
|
53
55
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
@@ -70,7 +72,14 @@ const OPENSPEC_SKILLS = [
|
|
|
70
72
|
"openspec-sync-specs",
|
|
71
73
|
"openspec-archive-change",
|
|
72
74
|
];
|
|
73
|
-
|
|
75
|
+
// `sync` is here because `AGENTS.md` gates it exactly as it gates archive —
|
|
76
|
+
// `keel gate change-close --action sync|archive` plus `keel-review-checklist`.
|
|
77
|
+
// Archive's surface said so and sync's said nothing, so an agent invoking
|
|
78
|
+
// `/opsx:sync` read generic upstream instructions with no mention of the gate
|
|
79
|
+
// that decides whether the sync may complete. `explore` is deliberately absent:
|
|
80
|
+
// it reads and reports, reaches no gate, and changes no state, so an overlay
|
|
81
|
+
// there would read as governance where there is none.
|
|
82
|
+
const OPENSPEC_OVERLAY_ACTIONS = ["propose", "apply", "archive", "sync"];
|
|
74
83
|
const OPENSPEC_SURFACE_OVERLAY_START =
|
|
75
84
|
`<!-- keel:openspec-surface-overlay version=${PACKAGE_JSON.version} -->`;
|
|
76
85
|
const OPENSPEC_SURFACE_OVERLAY_END =
|
|
@@ -177,6 +186,7 @@ function parseArgs(argv) {
|
|
|
177
186
|
guardSubcommand: null,
|
|
178
187
|
lensesSubcommand: null,
|
|
179
188
|
lensName: null,
|
|
189
|
+
labels: null,
|
|
180
190
|
openspecArgs: [],
|
|
181
191
|
force: false,
|
|
182
192
|
projectionEvent: null,
|
|
@@ -220,6 +230,14 @@ function parseArgs(argv) {
|
|
|
220
230
|
parsed.action = "lenses";
|
|
221
231
|
continue;
|
|
222
232
|
}
|
|
233
|
+
if (arg === "triage" && parsed.action === null && parsed.repo === null) {
|
|
234
|
+
parsed.action = "triage";
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (arg === "--labels" && parsed.action === "triage") {
|
|
238
|
+
parsed.labels = argv[++index] || "";
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
223
241
|
if (arg === "openspec" && parsed.action === null && parsed.repo === null) {
|
|
224
242
|
parsed.action = "openspec";
|
|
225
243
|
parsed.openspecArgs = argv.slice(index + 1);
|
|
@@ -471,7 +489,7 @@ function parseArgs(argv) {
|
|
|
471
489
|
fail(`invalid target: ${parsed.target}`);
|
|
472
490
|
}
|
|
473
491
|
if (
|
|
474
|
-
!["context", "gate", "capabilities", "project", "guard"].includes(
|
|
492
|
+
!["context", "gate", "capabilities", "project", "guard", "triage"].includes(
|
|
475
493
|
parsed.action
|
|
476
494
|
)
|
|
477
495
|
&& (
|
|
@@ -540,6 +558,16 @@ function parseArgs(argv) {
|
|
|
540
558
|
} else if (parsed.lensesSubcommand !== null || parsed.lensName !== null) {
|
|
541
559
|
fail("lens subcommands apply only to keel lenses");
|
|
542
560
|
}
|
|
561
|
+
if (parsed.action === "triage") {
|
|
562
|
+
if (parsed.labels === null) {
|
|
563
|
+
fail(
|
|
564
|
+
"keel triage requires --labels; Keel never fetches the issue, so pass "
|
|
565
|
+
+ "what `gh issue view --json labels` returned"
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
} else if (parsed.labels !== null) {
|
|
569
|
+
fail("--labels applies only to keel triage");
|
|
570
|
+
}
|
|
543
571
|
if (parsed.noGuard && parsed.action !== "gate") {
|
|
544
572
|
fail("--no-guard applies only to keel gate task-start");
|
|
545
573
|
}
|
|
@@ -593,31 +621,16 @@ function parseArgs(argv) {
|
|
|
593
621
|
return parsed;
|
|
594
622
|
}
|
|
595
623
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
} else {
|
|
607
|
-
candidates.push({ command: "python3", prefixArgs: [] });
|
|
608
|
-
candidates.push({ command: "python", prefixArgs: [] });
|
|
609
|
-
}
|
|
610
|
-
return candidates;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function commandExists(candidate) {
|
|
614
|
-
const result = spawnSync(
|
|
615
|
-
candidate.command,
|
|
616
|
-
[...candidate.prefixArgs, "--version"],
|
|
617
|
-
{ encoding: "utf8" }
|
|
618
|
-
);
|
|
619
|
-
return !result.error && result.status === 0;
|
|
620
|
-
}
|
|
624
|
+
// The interpreter rule lives in `scripts/run_python.js` and is imported rather
|
|
625
|
+
// than restated. This file used to carry its own candidate list that asked only
|
|
626
|
+
// whether a command runs, so `keel --doctor` reported `python3: ok` for the
|
|
627
|
+
// same 3.9.6 the runner refuses — measured 2026-08-02. Two statements of one
|
|
628
|
+
// threshold drift the moment either moves; one statement cannot.
|
|
629
|
+
const {
|
|
630
|
+
MINIMUM_PYTHON,
|
|
631
|
+
resolveInterpreter,
|
|
632
|
+
describeTried,
|
|
633
|
+
} = require(path.join(__dirname, "..", "scripts", "run_python.js"));
|
|
621
634
|
|
|
622
635
|
function npmCommand() {
|
|
623
636
|
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
@@ -667,6 +680,42 @@ function runCommand(command, args, options = {}) {
|
|
|
667
680
|
return typeof result.status === "number" ? result.status : 1;
|
|
668
681
|
}
|
|
669
682
|
|
|
683
|
+
// Which OpenSpec answered is not cosmetic. `openspecCandidates` prefers the
|
|
684
|
+
// installed dependency and otherwise falls back to PATH in silence, so a
|
|
685
|
+
// worktree with no `node_modules` validates against whatever version happens
|
|
686
|
+
// to be installed globally — measured here as 1.4.1 against a lockfile that
|
|
687
|
+
// resolves 1.6.0, which rejects a requirement the shipped template writes.
|
|
688
|
+
// A green pipeline and a red worktree were the same command run against two
|
|
689
|
+
// different programs. Keel states which one; it does not install or select.
|
|
690
|
+
function openspecReportedVersion(command) {
|
|
691
|
+
const result = spawnSync(command, ["--version"], {
|
|
692
|
+
encoding: "utf8",
|
|
693
|
+
shell: process.platform === "win32",
|
|
694
|
+
});
|
|
695
|
+
if (result.error || result.status !== 0) return null;
|
|
696
|
+
const match = `${result.stdout || ""}${result.stderr || ""}`.match(
|
|
697
|
+
/\d+\.\d+\.\d+/
|
|
698
|
+
);
|
|
699
|
+
return match ? match[0] : null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function lockedOpenSpecVersion() {
|
|
703
|
+
try {
|
|
704
|
+
const lock = JSON.parse(
|
|
705
|
+
fs.readFileSync(path.join(PACKAGE_ROOT, "package-lock.json"), "utf8")
|
|
706
|
+
);
|
|
707
|
+
for (const [name, entry] of Object.entries(lock.packages || {})) {
|
|
708
|
+
if (name.endsWith("@fission-ai/openspec") && entry && entry.version) {
|
|
709
|
+
return entry.version;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
} catch {
|
|
713
|
+
// No lockfile in a published install; there is then nothing to disagree
|
|
714
|
+
// with, which is not the same as agreement and is reported as such.
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
|
|
670
719
|
function findOpenSpecCommand() {
|
|
671
720
|
for (const command of openspecCandidates()) {
|
|
672
721
|
const status = runCommand(command, ["--version"], {
|
|
@@ -805,10 +854,8 @@ function runPython(script, args) {
|
|
|
805
854
|
return 1;
|
|
806
855
|
}
|
|
807
856
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
857
|
+
const { candidate, tried } = resolveInterpreter();
|
|
858
|
+
if (candidate) {
|
|
812
859
|
const result = spawnSync(
|
|
813
860
|
candidate.command,
|
|
814
861
|
[...candidate.prefixArgs, script, ...args],
|
|
@@ -817,8 +864,11 @@ function runPython(script, args) {
|
|
|
817
864
|
return typeof result.status === "number" ? result.status : 1;
|
|
818
865
|
}
|
|
819
866
|
|
|
867
|
+
const minimum = MINIMUM_PYTHON.join(".");
|
|
820
868
|
process.stderr.write(
|
|
821
|
-
|
|
869
|
+
`keel: Python ${minimum} or newer is required. Tried `
|
|
870
|
+
+ `${describeTried(tried) || "nothing"}. Install one, or set `
|
|
871
|
+
+ "KEEL_PYTHON.\n"
|
|
822
872
|
);
|
|
823
873
|
return 1;
|
|
824
874
|
}
|
|
@@ -978,12 +1028,12 @@ function openspecOverlaySurfacesForTarget(target, repo) {
|
|
|
978
1028
|
if (action === "propose" && target === "opencode") {
|
|
979
1029
|
return [];
|
|
980
1030
|
}
|
|
981
|
-
const skillName =
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1031
|
+
const skillName = {
|
|
1032
|
+
propose: "openspec-propose",
|
|
1033
|
+
apply: "openspec-apply-change",
|
|
1034
|
+
archive: "openspec-archive-change",
|
|
1035
|
+
sync: "openspec-sync-specs",
|
|
1036
|
+
}[action];
|
|
987
1037
|
return [
|
|
988
1038
|
{
|
|
989
1039
|
action,
|
|
@@ -997,11 +1047,23 @@ function openspecOverlaySurfacesForTarget(target, repo) {
|
|
|
997
1047
|
});
|
|
998
1048
|
}
|
|
999
1049
|
|
|
1050
|
+
// Derived from the managed set rather than written beside it. The label was
|
|
1051
|
+
// the literal string "apply/archive", which was correct while those were the
|
|
1052
|
+
// managed actions and became wrong — silently — the moment a third joined them.
|
|
1053
|
+
// `propose` is excluded because its overlay governs authoring rather than a
|
|
1054
|
+
// state-changing command, and the doctor line counts the command surfaces.
|
|
1055
|
+
function overlayActionLabel() {
|
|
1056
|
+
return OPENSPEC_OVERLAY_ACTIONS.filter((action) => action !== "propose")
|
|
1057
|
+
.join("/");
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1000
1060
|
function overlayTitleForAction(action) {
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
: "Keel Archive Overlay"
|
|
1061
|
+
return {
|
|
1062
|
+
propose: "Keel Authoring Overlay",
|
|
1063
|
+
apply: "Keel Apply Overlay",
|
|
1064
|
+
archive: "Keel Archive Overlay",
|
|
1065
|
+
sync: "Keel Sync Overlay",
|
|
1066
|
+
}[action];
|
|
1005
1067
|
}
|
|
1006
1068
|
|
|
1007
1069
|
function keelOpenSpecOverlay(action) {
|
|
@@ -1025,6 +1087,33 @@ function keelOpenSpecOverlay(action) {
|
|
|
1025
1087
|
];
|
|
1026
1088
|
return lines.join("\n");
|
|
1027
1089
|
}
|
|
1090
|
+
// Sync mirrors archive's structure and not its content: the two are gated and
|
|
1091
|
+
// owned identically, so the ownership, subagent, and delegation-language
|
|
1092
|
+
// rules are the same statements with `sync` in them. What differs is the
|
|
1093
|
+
// artifact consequence — archive warns about re-applying a promoted delta,
|
|
1094
|
+
// and sync is the thing that promotes it, so it says so from its own side. A
|
|
1095
|
+
// reader who only ever sees one of the two surfaces still learns the pairing.
|
|
1096
|
+
const syncBody = [
|
|
1097
|
+
"- The current agent owns the sync decision and must verify task evidence, follow-up ownership, and completion gates before proceeding.",
|
|
1098
|
+
"- Sync completion is gated by `keel gate change-close --action sync` plus `keel-review-checklist`; there is no runtime hook for it, so running the gate is the agent's own step.",
|
|
1099
|
+
"- Before syncing, each related critical expectation must have behavior evidence, a durable follow-up owner, or an explicit discard reason.",
|
|
1100
|
+
"- Target-native subagents may help with bounded assessment or evidence production only; they cannot sync, change acceptance, or bypass completion gates.",
|
|
1101
|
+
"- Do not treat generic OpenSpec sync delegation language as authority to transfer Keel ownership.",
|
|
1102
|
+
"- Invoke OpenSpec through `keel openspec` (for example `keel openspec validate`); a bare `openspec` command may not be on PATH.",
|
|
1103
|
+
"- Syncing promotes the change's spec delta into `openspec/specs/`. A later archive of the same change must use `--skip-specs`, because archive is not idempotent over an already-promoted delta.",
|
|
1104
|
+
];
|
|
1105
|
+
if (action === "sync") {
|
|
1106
|
+
return [
|
|
1107
|
+
OPENSPEC_SURFACE_OVERLAY_START,
|
|
1108
|
+
`## ${overlayTitleForAction(action)}`,
|
|
1109
|
+
"",
|
|
1110
|
+
"Keel rules below take precedence over conflicting generic OpenSpec instructions in this file.",
|
|
1111
|
+
"",
|
|
1112
|
+
...syncBody,
|
|
1113
|
+
OPENSPEC_SURFACE_OVERLAY_END,
|
|
1114
|
+
"",
|
|
1115
|
+
].join("\n");
|
|
1116
|
+
}
|
|
1028
1117
|
const actionBody =
|
|
1029
1118
|
action === "apply"
|
|
1030
1119
|
? [
|
|
@@ -1033,7 +1122,7 @@ function keelOpenSpecOverlay(action) {
|
|
|
1033
1122
|
"- Run the Slice Start Gate: selected current slices must name source expectations and include Read, Touch, Acceptance, Commands, and Stop/Autonomy boundaries before implementation.",
|
|
1034
1123
|
"- Rough future slices may remain drafts, but cannot be selected for implementation or marked complete.",
|
|
1035
1124
|
"- Obey the selected task contract: Read is required starting context, Touch is the write boundary, Commands prove Acceptance, and the Autonomy boundary controls fallback decisions.",
|
|
1036
|
-
"-
|
|
1125
|
+
"- A target-native helper returns report/evidence only, and a declared delegate may write inside `Touch`; both cannot mark tasks complete, update OpenSpec state, commit, sync, archive, or change Acceptance.",
|
|
1037
1126
|
"- The current agent reviews all subagent output, command evidence, and diffs before marking any task complete.",
|
|
1038
1127
|
"- When implementation exposes a material expectation, acceptance boundary, or user-owned decision absent from durable authority, stop before implementing that choice, rerun `keel-align-expectations`, and reauthor the affected proposal/design/spec/task authority first.",
|
|
1039
1128
|
"- A discovered repository fact that does not change accepted behavior or scope may be recorded and execution continues inside the existing task boundary without a product interview.",
|
|
@@ -1063,9 +1152,11 @@ function keelOpenSpecOverlay(action) {
|
|
|
1063
1152
|
"### Target-native subagent gate",
|
|
1064
1153
|
"",
|
|
1065
1154
|
"- The current agent remains responsible for Keel ownership, task/archive decisions, scope control, and final reporting.",
|
|
1066
|
-
"- Use a target-native subagent
|
|
1067
|
-
"- Target-native subagents return report/evidence only
|
|
1068
|
-
"-
|
|
1155
|
+
"- Use a target-native subagent when the current agent decides it is useful for a bounded helper step, or as a delegate implementing the selected task where `delegation:` is declared in `keel/config.yaml` and a guard manifest is active.",
|
|
1156
|
+
"- Target-native subagents acting as helpers return report/evidence only. A delegate may write, and only inside `Touch`; its reported command results are a claim, and the current agent re-runs each `M<n>` check itself before recording Evidence.",
|
|
1157
|
+
"- Delegation is refused with no active guard manifest, because an absent manifest passes every write through silently and looks identical to a checked one.",
|
|
1158
|
+
"- Neither may mark tasks complete, update OpenSpec state, commit, sync, archive, or change Acceptance; the current agent reviews all output before acting.",
|
|
1159
|
+
"- The subagent brief must name the selected change/task, required read context, allowed write boundary or read-only diagnostic scope, expected commands/evidence, and prohibited actions. Compile it with `keel project --event subagent-start --authorize subagent`; Keel adds no separate carrier because the host already has one.",
|
|
1069
1160
|
"- Prohibited actions include scope expansion, Acceptance changes, completion marking, sync/archive decisions, commits, handoff changes, and cross-runtime delegation unless the selected task or user explicitly authorizes them.",
|
|
1070
1161
|
...actionBody,
|
|
1071
1162
|
OPENSPEC_SURFACE_OVERLAY_END,
|
|
@@ -1271,13 +1362,13 @@ function printTargetSurface(repo, target) {
|
|
|
1271
1362
|
const overlayCounts = countOpenSpecOverlays(overlayPaths);
|
|
1272
1363
|
const overlayDetail =
|
|
1273
1364
|
surfaceStatus(overlayCounts) === "ok"
|
|
1274
|
-
? formatCount(overlayCounts,
|
|
1365
|
+
? formatCount(overlayCounts, `${overlayActionLabel()} skills and commands`)
|
|
1275
1366
|
: `${formatCount(
|
|
1276
1367
|
overlayCounts,
|
|
1277
|
-
|
|
1368
|
+
`${overlayActionLabel()} skills and commands`
|
|
1278
1369
|
)}; ${overlayRemediation(target)}`;
|
|
1279
1370
|
printDoctorLine(
|
|
1280
|
-
|
|
1371
|
+
`Keel ${overlayActionLabel()} overlay`,
|
|
1281
1372
|
surfaceStatus(overlayCounts),
|
|
1282
1373
|
overlayDetail
|
|
1283
1374
|
);
|
|
@@ -1297,12 +1388,28 @@ function runDoctor(options) {
|
|
|
1297
1388
|
const repo = path.resolve(options.repo || process.cwd());
|
|
1298
1389
|
process.stdout.write(`keel doctor for ${repo}\n`);
|
|
1299
1390
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
)
|
|
1391
|
+
// The version is printed, not just a verdict: `ok` with no number behind it
|
|
1392
|
+
// is a claim the reader cannot check, and it was the wrong claim here for as
|
|
1393
|
+
// long as this line existed.
|
|
1394
|
+
const minimumPython = MINIMUM_PYTHON.join(".");
|
|
1395
|
+
const interpreter = resolveInterpreter();
|
|
1396
|
+
if (interpreter.candidate) {
|
|
1397
|
+
printDoctorLine(
|
|
1398
|
+
"python3",
|
|
1399
|
+
"ok",
|
|
1400
|
+
`${formatCommand(interpreter.candidate.command, interpreter.candidate.prefixArgs)}`
|
|
1401
|
+
+ ` (${interpreter.version})`
|
|
1402
|
+
);
|
|
1403
|
+
} else {
|
|
1404
|
+
const runnable = interpreter.tried.filter((entry) => entry.version);
|
|
1405
|
+
printDoctorLine(
|
|
1406
|
+
"python3",
|
|
1407
|
+
runnable.length > 0 ? "problem" : "missing",
|
|
1408
|
+
runnable.length > 0
|
|
1409
|
+
? `needs ${minimumPython} or newer; found ${describeTried(runnable)}`
|
|
1410
|
+
: `needs ${minimumPython} or newer; set KEEL_PYTHON or install Python 3`
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1306
1413
|
|
|
1307
1414
|
const openspec = findOpenSpecCommand();
|
|
1308
1415
|
if (!openspec) {
|
|
@@ -1318,12 +1425,26 @@ function runDoctor(options) {
|
|
|
1318
1425
|
stdio: "ignore",
|
|
1319
1426
|
silentNotFound: true,
|
|
1320
1427
|
}) === 0;
|
|
1428
|
+
const resolvedVersion = openspecReportedVersion(openspec);
|
|
1429
|
+
const locked = lockedOpenSpecVersion();
|
|
1430
|
+
const mismatched = Boolean(
|
|
1431
|
+
resolvedVersion && locked && resolvedVersion !== locked
|
|
1432
|
+
);
|
|
1433
|
+
const where = bareOpenSpecOnPath
|
|
1434
|
+
? openspec
|
|
1435
|
+
: `${openspec} is keel-resolvable but bare \`openspec\` is not on PATH — use \`keel openspec\``;
|
|
1436
|
+
const versions = `${resolvedVersion || "version unreadable"}, lockfile ${
|
|
1437
|
+
locked || "unreadable"
|
|
1438
|
+
}`;
|
|
1321
1439
|
printDoctorLine(
|
|
1322
1440
|
"openspec",
|
|
1323
|
-
bareOpenSpecOnPath ? "
|
|
1324
|
-
|
|
1325
|
-
?
|
|
1326
|
-
|
|
1441
|
+
mismatched || !bareOpenSpecOnPath ? "warning" : "ok",
|
|
1442
|
+
mismatched
|
|
1443
|
+
? `${where} (${versions}) — validation is answering from a different `
|
|
1444
|
+
+ "build than this repository pins, which is what a green pipeline "
|
|
1445
|
+
+ "and a red worktree look like. Keel reports which one answered "
|
|
1446
|
+
+ "and selects none."
|
|
1447
|
+
: `${where} (${versions})`
|
|
1327
1448
|
);
|
|
1328
1449
|
}
|
|
1329
1450
|
|
|
@@ -1355,6 +1476,7 @@ function runDoctor(options) {
|
|
|
1355
1476
|
printLensSurface(repo, options.target);
|
|
1356
1477
|
const authorizationOk = printStandingAuthorizationSurface(repo);
|
|
1357
1478
|
printPrecedentSurface(repo);
|
|
1479
|
+
printTriageSurface(repo);
|
|
1358
1480
|
printFastPrePushSurface(repo);
|
|
1359
1481
|
printSourceRepoCliResolution(repo);
|
|
1360
1482
|
|
|
@@ -1432,6 +1554,19 @@ function printStandingAuthorizationSurface(repo) {
|
|
|
1432
1554
|
return true;
|
|
1433
1555
|
}
|
|
1434
1556
|
|
|
1557
|
+
function printTriageSurface(repo) {
|
|
1558
|
+
process.stdout.write("\nUnattended triage:\n");
|
|
1559
|
+
const { labels } = readTriagePolicy(repo);
|
|
1560
|
+
printDoctorLine(
|
|
1561
|
+
"triage",
|
|
1562
|
+
labels.length > 0 ? "ok" : "none",
|
|
1563
|
+
labels.length > 0
|
|
1564
|
+
? `issues labelled ${labels.join(", ")} may start work unattended; `
|
|
1565
|
+
+ "admission decides nothing after it, and no declaration authorizes a merge"
|
|
1566
|
+
: "undeclared; no issue starts work unattended"
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1435
1570
|
function printPrecedentSurface(repo) {
|
|
1436
1571
|
process.stdout.write("\nPrecedent store:\n");
|
|
1437
1572
|
const store = readPrecedentStore(repo);
|
|
@@ -1713,6 +1848,31 @@ function runAction(options) {
|
|
|
1713
1848
|
: 3;
|
|
1714
1849
|
}
|
|
1715
1850
|
|
|
1851
|
+
if (options.action === "triage") {
|
|
1852
|
+
const repo = path.resolve(options.repo || process.cwd());
|
|
1853
|
+
const labels = String(options.labels || "")
|
|
1854
|
+
.split(",")
|
|
1855
|
+
.map((label) => label.trim())
|
|
1856
|
+
.filter(Boolean);
|
|
1857
|
+
const verdict = triageIssue(repo, labels);
|
|
1858
|
+
const payload = {
|
|
1859
|
+
schemaVersion: 1,
|
|
1860
|
+
command: "triage",
|
|
1861
|
+
...verdict,
|
|
1862
|
+
warnings: [
|
|
1863
|
+
"Admission starts work and authorizes nothing after it; every gate, "
|
|
1864
|
+
+ "evidence requirement, Review, and the write guard still apply.",
|
|
1865
|
+
"An unattended run may open a pull request and may not merge one.",
|
|
1866
|
+
"Keel schedules nothing; the loop belongs to the host runtime.",
|
|
1867
|
+
],
|
|
1868
|
+
};
|
|
1869
|
+
if (options.json) {
|
|
1870
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
1871
|
+
} else {
|
|
1872
|
+
process.stdout.write(`Triage: ${verdict.status}\n${verdict.reason}\n`);
|
|
1873
|
+
}
|
|
1874
|
+
return 0;
|
|
1875
|
+
}
|
|
1716
1876
|
if (options.action === "lenses") {
|
|
1717
1877
|
if (options.dryRun || options.forceTemplateUpdate || options.updateSource) {
|
|
1718
1878
|
fail("lenses does not accept install or update options");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.14.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.14.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: keel-single-task-goal-claude
|
|
3
|
-
description: Thin Claude activation adapter for one authorized OpenSpec task; single /goal within the 4,000-character budget
|
|
3
|
+
description: Thin Claude activation adapter for one authorized OpenSpec task; single /goal within the 4,000-character budget; the current agent keeps sole write authority.
|
|
4
4
|
target: claude
|
|
5
5
|
role: single-task-goal-activation
|
|
6
6
|
---
|
|
@@ -11,6 +11,7 @@ Thin Claude adapter for the `keel-run-single-task-goal` skill. It activates one
|
|
|
11
11
|
|
|
12
12
|
- Activation: compile the view with `keel project goal --target claude --change C --task T --json`; the goal condition stays within the 4,000-character budget, and Keel refuses activation rather than omit Acceptance, fingerprint, or stop authority.
|
|
13
13
|
- Evidence: Claude's evaluator is transcript-only, so surface every command result and gate outcome before any success claim; only `keel gate task-complete` plus the current agent's durable checkbox complete the task.
|
|
14
|
-
- Helpers: Claude subagents
|
|
14
|
+
- Helpers: Claude subagents used as helpers are bounded read-only evidence producers via `keel project helper`; they carry no write, delegation, acceptance, or completion authority, and their returns are accepted only after repository byte identity.
|
|
15
|
+
- Delegates: where `delegation:` is declared and a guard manifest is active, a subagent may implement inside `Touch`. The current agent keeps sole write authority, re-runs each `M<n>` check itself before recording Evidence, and owns Review, gates, the checkbox, and completion.
|
|
15
16
|
- Stop: terminate after one task on completion, drift, blocker, or premature native success; continuing requires a new explicit authorization and start fingerprint.
|
|
16
17
|
- Fallback: with disabled hooks, managed policy, or missing trust, report and run the identical manual Keel loop.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: keel-single-task-goal-codex
|
|
3
|
-
description: Thin Codex activation adapter for one authorized OpenSpec task; bounded goal execution
|
|
3
|
+
description: Thin Codex activation adapter for one authorized OpenSpec task; bounded goal execution; the current agent keeps sole write authority.
|
|
4
4
|
target: codex
|
|
5
5
|
role: single-task-goal-activation
|
|
6
6
|
---
|
|
@@ -10,7 +10,7 @@ role: single-task-goal-activation
|
|
|
10
10
|
Thin Codex adapter for the `keel-run-single-task-goal` skill. It activates one bounded goal for exactly one authorized OpenSpec task and never introduces a scheduler, global stop hook, agent team, or cross-task authority.
|
|
11
11
|
|
|
12
12
|
- Activation: compile the view with `keel project goal --target codex --change C --task T --json`, then follow the single-task goal lifecycle. Where no callable goal surface exists, surface the exact command and treat the capability as advisory.
|
|
13
|
-
- Ownership: the current agent stays the sole
|
|
13
|
+
- Ownership: the current agent stays the sole holder of write authority and owns Review, gates, the checkbox, and completion; a native evaluator success never completes the task. A declared delegate writes only inside `Touch`, and the current agent re-runs each `M<n>` check before recording Evidence.
|
|
14
14
|
- Helpers: Codex subagents are used only as bounded read-only evidence producers via `keel project helper`; they carry no write, delegation, acceptance, or completion authority, and their returns are accepted only after repository byte identity.
|
|
15
15
|
- Stop: terminate after one task on completion, drift, blocker, or premature native success; continuing requires a new explicit authorization and start fingerprint.
|
|
16
16
|
- Fallback: without a callable Codex surface, run the identical manual Keel loop.
|
|
@@ -109,6 +109,41 @@ function taskIsChecked(repo, manifest) {
|
|
|
109
109
|
return Boolean(match && match[1].toLowerCase() === "x");
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// A path is not the file it names. `path.resolve` never follows a symbolic
|
|
113
|
+
// link, while the `cwd` the operating system reports usually already has, so
|
|
114
|
+
// one file could be spelled two ways and get two containment answers: measured
|
|
115
|
+
// as a write denied by its resolved path and allowed through a link to the
|
|
116
|
+
// same directory, which walked straight past the manifest. The guarded target
|
|
117
|
+
// is usually a file about to be created, so what can be resolved is its
|
|
118
|
+
// nearest existing ancestor; when nothing resolves, the unresolved path is
|
|
119
|
+
// what remains, which is exactly the comparison that shipped.
|
|
120
|
+
//
|
|
121
|
+
// `src/core/helper.js` answers the same question and must keep answering it
|
|
122
|
+
// the same way. It is deliberately not shared as an import: this hook is a
|
|
123
|
+
// standalone script the host executes, and depending on `src/core` would make
|
|
124
|
+
// the guard fail wherever the package layout differs from this repository's.
|
|
125
|
+
function realPathOrNearest(target) {
|
|
126
|
+
let current = path.resolve(target);
|
|
127
|
+
const trailing = [];
|
|
128
|
+
for (;;) {
|
|
129
|
+
try {
|
|
130
|
+
return path.join(fs.realpathSync(current), ...trailing);
|
|
131
|
+
} catch {
|
|
132
|
+
const parent = path.dirname(current);
|
|
133
|
+
if (parent === current) return path.resolve(target);
|
|
134
|
+
trailing.unshift(path.basename(current));
|
|
135
|
+
current = parent;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function repoRelative(repo, target) {
|
|
141
|
+
return path.relative(
|
|
142
|
+
realPathOrNearest(repo),
|
|
143
|
+
realPathOrNearest(path.resolve(repo, target))
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
112
147
|
function main() {
|
|
113
148
|
let event = {};
|
|
114
149
|
try {
|
|
@@ -130,8 +165,13 @@ function main() {
|
|
|
130
165
|
// guard never protected; that ordering has already failed twice.
|
|
131
166
|
const target = event.tool_input ? event.tool_input[pathField] : null;
|
|
132
167
|
if (typeof target !== "string" || !target) return 0;
|
|
133
|
-
const relative =
|
|
134
|
-
if (
|
|
168
|
+
const relative = repoRelative(repo, target);
|
|
169
|
+
if (
|
|
170
|
+
!relative
|
|
171
|
+
|| relative === ".."
|
|
172
|
+
|| relative.startsWith(`..${path.sep}`)
|
|
173
|
+
|| path.isAbsolute(relative)
|
|
174
|
+
) {
|
|
135
175
|
return 0;
|
|
136
176
|
}
|
|
137
177
|
const candidate = relative.replace(/\\/g, "/");
|
|
@@ -154,6 +194,29 @@ function main() {
|
|
|
154
194
|
return 0;
|
|
155
195
|
}
|
|
156
196
|
const pointer = `${manifest.change}#${manifest.task}`;
|
|
197
|
+
// Two states used to arrive at the same place, and they need different
|
|
198
|
+
// answers. A task id absent from a live `tasks.md` is a parse miss the guard
|
|
199
|
+
// must not guess about — see `taskIsChecked`. A change directory that is not
|
|
200
|
+
// there at all is a fact: the manifest points at work that has been archived
|
|
201
|
+
// or removed, and `keel guard status` already classifies it as drifted.
|
|
202
|
+
//
|
|
203
|
+
// Untreated, the archived case was wrong in both directions. A write outside
|
|
204
|
+
// the stale Touch was denied by a message naming a task in an archived change
|
|
205
|
+
// and telling the reader to reauthorize it, which cannot be done; a write
|
|
206
|
+
// *inside* it passed silently, so an archived task went on granting write
|
|
207
|
+
// authority. Denying here fixes both, and denying rather than allowing is
|
|
208
|
+
// what stops `openspec archive` from quietly disabling the guard.
|
|
209
|
+
const changeDir = path.join(repo, "openspec", "changes", manifest.change);
|
|
210
|
+
if (!fs.existsSync(changeDir)) {
|
|
211
|
+
deny(
|
|
212
|
+
`Keel write guard: this manifest is stale — it guards ${pointer}, but `
|
|
213
|
+
+ `openspec/changes/${manifest.change} no longer exists, so the task it `
|
|
214
|
+
+ "names cannot be reauthorized and its Touch list authorizes nothing. "
|
|
215
|
+
+ "File edits fail closed. Run `keel guard clear`, then start the task "
|
|
216
|
+
+ "you are actually working on with `keel gate task-start`."
|
|
217
|
+
);
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
157
220
|
// The record layer: the guarded change's own directory holds the records the
|
|
158
221
|
// task produces — its checkbox, Evidence, and Review — not the product it
|
|
159
222
|
// changes. `keel gate task-complete` already refuses to attribute this
|