@christang/keel 5.20.0 → 5.44.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 +40 -11
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +4 -1
- package/bin/keel.js +127 -22
- 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/skills/keel-align-expectations/SKILL.md +4 -18
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +1 -1
- package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +1 -1
- package/scripts/install_to_repo.py +116 -3
- package/scripts/validate_plugin.py +5027 -973
- package/src/core/config.js +199 -27
- package/src/core/context.js +9 -0
- package/src/core/gates.js +357 -57
- package/src/core/guard.js +77 -16
- package/src/core/task-contract.js +106 -5
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ because a permission granted in conversation does not survive a context reset. D
|
|
|
132
132
|
`keel/config.yaml` instead:
|
|
133
133
|
|
|
134
134
|
```yaml
|
|
135
|
-
authorize: # accepted names: commit, push, release, archive
|
|
135
|
+
authorize: # accepted names: commit, push, release, archive, continuation
|
|
136
136
|
- commit
|
|
137
137
|
- push
|
|
138
138
|
```
|
|
@@ -141,6 +141,16 @@ A task that authors no `Autonomy boundary:` inherits the declaration, and the co
|
|
|
141
141
|
names `keel/config.yaml` as that entry's source so an inherited authorization is never mistaken
|
|
142
142
|
for one the task decided. A task that authors its own boundary keeps it.
|
|
143
143
|
|
|
144
|
+
`continuation`, the fifth name, is the between-task one: it covers exactly the boundary between a
|
|
145
|
+
durably complete task and the next unchecked task of the same change, inside a change whose
|
|
146
|
+
`tasks.md` you approved — the stop that re-asks for an approval already given. It removes only that
|
|
147
|
+
confirmation: the next task still starts through `keel gate task-start` with its own recorded
|
|
148
|
+
fingerprint, every gate, evidence requirement, semantic Review, and the write guard run unchanged,
|
|
149
|
+
and a stop with its own trigger — a blocker, fingerprint drift, an out-of-scope need, an unresolved
|
|
150
|
+
question — still stops. It authorizes no repository action and schedules nothing. On an older Keel
|
|
151
|
+
whose vocabulary predates the word, the entry is unrecognized and the whole declaration authorizes
|
|
152
|
+
nothing until corrected — fail-closed, never a silent grant.
|
|
153
|
+
|
|
144
154
|
Three things the declaration is not:
|
|
145
155
|
|
|
146
156
|
- **Not a way past a gate.** It authorizes the action, never the proof. `keel gate task-complete`
|
|
@@ -148,7 +158,7 @@ Three things the declaration is not:
|
|
|
148
158
|
anything.
|
|
149
159
|
- **Not a trigger.** It removes a confirmation, not the step that reaches the action. Nothing
|
|
150
160
|
schedules itself, and no next task is selected for you.
|
|
151
|
-
- **Not open-ended.** The
|
|
161
|
+
- **Not open-ended.** The five names above are the whole vocabulary. An unrecognized entry is
|
|
152
162
|
reported with the accepted names and the declaration authorizes nothing until you fix it — a
|
|
153
163
|
typo never becomes a silent grant.
|
|
154
164
|
|
|
@@ -198,21 +208,40 @@ The last thing a loop needs is permission to *start*. Declare which issues may b
|
|
|
198
208
|
being asked about:
|
|
199
209
|
|
|
200
210
|
```yaml
|
|
201
|
-
triage: #
|
|
202
|
-
|
|
211
|
+
triage: # either source admits; absent means nothing does
|
|
212
|
+
labels: # labels the issue carries
|
|
213
|
+
- auto
|
|
214
|
+
issues: # issue numbers, declared here rather than on the issue
|
|
215
|
+
- 62
|
|
203
216
|
```
|
|
204
217
|
|
|
218
|
+
A bare list directly under `triage:` still means labels, so a declaration written before the second
|
|
219
|
+
source existed keeps its exact meaning.
|
|
220
|
+
|
|
205
221
|
```bash
|
|
206
|
-
gh issue view 42 --json labels
|
|
222
|
+
gh issue view 42 --json labels,number \
|
|
223
|
+
--jq '"--labels \([.labels[].name]|join(",")) --issue \(.number)"' \
|
|
224
|
+
| xargs keel triage
|
|
207
225
|
```
|
|
208
226
|
|
|
209
227
|
**Keel never fetches the issue.** You pass what `gh` returned, and the evaluation stays local,
|
|
210
228
|
offline and deterministic — the same properties that make every other Keel answer worth trusting.
|
|
229
|
+
At least one of `--labels` / `--issue` is required; supplying neither is asking for a fetch. A
|
|
230
|
+
`triage:` block Keel cannot fully read admits nothing at all and names the entry that failed —
|
|
231
|
+
granting the readable half would grant the entries beside your typo.
|
|
232
|
+
|
|
233
|
+
**One issue is the unit, in both sources.** A person applies a label, or types a number, for one
|
|
234
|
+
specific issue, so the policy admits a class you curate one issue at a time — not a guess about
|
|
235
|
+
which issues look easy, which is exactly the judgement that should not be automated.
|
|
236
|
+
|
|
237
|
+
They differ in **where your decision is written down**, which is why both exist:
|
|
211
238
|
|
|
212
|
-
A **label**
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
automation can label issues,
|
|
239
|
+
- A **label** records it on the issue. In a repository whose issues come from other people, that
|
|
240
|
+
means an operations switch sits in the vocabulary you asked reporters to classify with, visible
|
|
241
|
+
and editable by whoever filed it. Keel also cannot check that a human applied it; if your
|
|
242
|
+
automation can label issues, that source is wider than it looks.
|
|
243
|
+
- An **issue number** records it in your own file. The reporter never sees it, only a committer can
|
|
244
|
+
change it, it shows up in a diff, and you can revoke one entry without touching the rest.
|
|
216
245
|
|
|
217
246
|
**Admission answers "may this begin" and nothing after it.** Alignment still escalates every
|
|
218
247
|
material choice, every gate still runs, and the write guard still binds. In particular:
|
|
@@ -324,8 +353,8 @@ keel lenses list
|
|
|
324
353
|
keel lenses add <name> [--force]
|
|
325
354
|
|
|
326
355
|
# 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]
|
|
356
|
+
# Keel never fetches the issue; pass what gh returned. At least one of the two.
|
|
357
|
+
keel triage [--labels <l1,l2>] [--issue <n>] [--json]
|
|
329
358
|
|
|
330
359
|
# Install / maintenance
|
|
331
360
|
keel --init | --install | --check | --doctor | --uninstall [--target <t>] [--dry-run]
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
- [ ] 1.1 <!-- Task description -->
|
|
19
19
|
- Covers:
|
|
20
|
-
- <source expectation: spec
|
|
20
|
+
- <source expectation: spec authority as `capability / requirement` or `capability / requirement / scenario` — a scenario is named in the THIRD segment, never the second, hidden-knowledge assumption, domain lens requirement, or critical D<n>/F<n>/A<n>/Q<n>; a Q<n> that OPENS an entry is an open question and requires an authorized fallback, while one named inside an entry about a fact is a citation and does not>
|
|
21
21
|
- Touch:
|
|
22
22
|
- <path>
|
|
23
23
|
- Verify:
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
- Scope check: pending
|
|
51
51
|
- Findings: pending
|
|
52
52
|
- Blocker: none
|
|
53
|
+
- Reauthorizations: none
|
|
53
54
|
|
|
54
55
|
<!-- Exceptional boundaries are declared only when they differ from defaults:
|
|
55
56
|
- Mode: diagnose-only or repo-action (both with `Touch: none`), or plan-first.
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
- Scope check: pending
|
|
88
89
|
- Findings: pending
|
|
89
90
|
- Blocker: none
|
|
91
|
+
- Reauthorizations: none
|
|
90
92
|
|
|
91
93
|
## 3. <!-- Task Group Name -->
|
|
92
94
|
|
|
@@ -117,6 +119,7 @@
|
|
|
117
119
|
- Scope check: pending
|
|
118
120
|
- Findings: pending
|
|
119
121
|
- Blocker: none
|
|
122
|
+
- Reauthorizations: none
|
|
120
123
|
|
|
121
124
|
## Invalidates
|
|
122
125
|
|
package/bin/keel.js
CHANGED
|
@@ -97,6 +97,7 @@ Usage:
|
|
|
97
97
|
keel gate task-start|task-complete|change-close [repo] [--change name] [--task id] [--action sync|archive] [--base git-ref] [--no-guard] [--record] [--json]
|
|
98
98
|
keel guard start|status|clear [repo] [--change name] [--task id] [--force] [--json]
|
|
99
99
|
keel lenses list|add [name] [repo] [--force]
|
|
100
|
+
keel triage [repo] [--labels <l1,l2>] [--issue <n>] [--json]
|
|
100
101
|
keel openspec [args...]
|
|
101
102
|
keel --init [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update]
|
|
102
103
|
keel --install [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update] [--with-git-hooks]
|
|
@@ -134,6 +135,8 @@ Examples:
|
|
|
134
135
|
keel lenses list
|
|
135
136
|
keel lenses add web
|
|
136
137
|
keel lenses add web --force
|
|
138
|
+
keel triage --labels auto
|
|
139
|
+
keel triage --issue 42
|
|
137
140
|
keel --init
|
|
138
141
|
keel --install
|
|
139
142
|
keel --install --target codex
|
|
@@ -187,6 +190,7 @@ function parseArgs(argv) {
|
|
|
187
190
|
lensesSubcommand: null,
|
|
188
191
|
lensName: null,
|
|
189
192
|
labels: null,
|
|
193
|
+
issue: null,
|
|
190
194
|
openspecArgs: [],
|
|
191
195
|
force: false,
|
|
192
196
|
projectionEvent: null,
|
|
@@ -238,6 +242,10 @@ function parseArgs(argv) {
|
|
|
238
242
|
parsed.labels = argv[++index] || "";
|
|
239
243
|
continue;
|
|
240
244
|
}
|
|
245
|
+
if (arg === "--issue" && parsed.action === "triage") {
|
|
246
|
+
parsed.issue = argv[++index] || "";
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
241
249
|
if (arg === "openspec" && parsed.action === null && parsed.repo === null) {
|
|
242
250
|
parsed.action = "openspec";
|
|
243
251
|
parsed.openspecArgs = argv.slice(index + 1);
|
|
@@ -559,14 +567,25 @@ function parseArgs(argv) {
|
|
|
559
567
|
fail("lens subcommands apply only to keel lenses");
|
|
560
568
|
}
|
|
561
569
|
if (parsed.action === "triage") {
|
|
562
|
-
|
|
570
|
+
// At least one attribute, rather than labels specifically: a repository
|
|
571
|
+
// that admits by issue number alone should not have to pass an empty label
|
|
572
|
+
// list to be answered. The requirement stays because Keel never fetches the
|
|
573
|
+
// issue, so a caller supplying nothing is expecting a fetch.
|
|
574
|
+
if (parsed.labels === null && parsed.issue === null) {
|
|
563
575
|
fail(
|
|
564
|
-
"keel triage requires --labels; Keel never fetches the
|
|
565
|
-
+ "what `gh issue view --json labels` returned"
|
|
576
|
+
"keel triage requires --labels or --issue; Keel never fetches the "
|
|
577
|
+
+ "issue, so pass what `gh issue view --json labels,number` returned"
|
|
566
578
|
);
|
|
567
579
|
}
|
|
568
|
-
|
|
569
|
-
|
|
580
|
+
if (parsed.issue !== null && !/^[1-9]\d*$/.test(parsed.issue)) {
|
|
581
|
+
fail(
|
|
582
|
+
`--issue takes an issue number as a bare number, such as --issue 62; `
|
|
583
|
+
+ `got ${parsed.issue || "nothing"}`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
if (parsed.labels !== null) fail("--labels applies only to keel triage");
|
|
588
|
+
if (parsed.issue !== null) fail("--issue applies only to keel triage");
|
|
570
589
|
}
|
|
571
590
|
if (parsed.noGuard && parsed.action !== "gate") {
|
|
572
591
|
fail("--no-guard applies only to keel gate task-start");
|
|
@@ -1188,6 +1207,70 @@ function mergeOpenSpecSurfaceOverlay(content, action) {
|
|
|
1188
1207
|
return `${content}${separator}${overlay}`;
|
|
1189
1208
|
}
|
|
1190
1209
|
|
|
1210
|
+
// The block plus the whitespace the merge above inserted in front of it, and
|
|
1211
|
+
// nothing else. Removing only the marked span leaves the separator behind, and
|
|
1212
|
+
// the file it is left in belongs to OpenSpec — so the match takes the newlines
|
|
1213
|
+
// immediately before the block and the one that closes it, and the replacement
|
|
1214
|
+
// puts a single newline back. A blank line anywhere else in the file is not
|
|
1215
|
+
// this code's business.
|
|
1216
|
+
const OPENSPEC_SURFACE_OVERLAY_REMOVE_RE =
|
|
1217
|
+
/\n*<!--\s*keel:openspec-surface-overlay(?:\s+[^>]*)?\s*-->[\s\S]*?<!--\s*keel:openspec-surface-overlay:end\s*-->[ \t]*\n?/;
|
|
1218
|
+
|
|
1219
|
+
function stripOpenSpecSurfaceOverlay(content) {
|
|
1220
|
+
let next = content;
|
|
1221
|
+
// A loop rather than one replace: the merge only ever writes one block, so a
|
|
1222
|
+
// file carrying two is already unexpected, and leaving one of them behind is
|
|
1223
|
+
// the outcome this is cheapest to rule out.
|
|
1224
|
+
while (OPENSPEC_SURFACE_OVERLAY_RE.test(next)) {
|
|
1225
|
+
next = next.replace(OPENSPEC_SURFACE_OVERLAY_REMOVE_RE, (match, offset) =>
|
|
1226
|
+
offset === 0 ? "" : "\n"
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
return next;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function removeOpenSpecSurfaceOverlay(repo, target, options = {}) {
|
|
1233
|
+
const counts = {
|
|
1234
|
+
removed: 0,
|
|
1235
|
+
absent: 0,
|
|
1236
|
+
missing: 0,
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
for (const surface of openspecOverlaySurfacesForTarget(target, repo)) {
|
|
1240
|
+
// `keel --install` without `--init` never creates the OpenSpec surfaces,
|
|
1241
|
+
// so a repository can legitimately reach uninstall with none of them.
|
|
1242
|
+
if (!fs.existsSync(surface.path)) {
|
|
1243
|
+
counts.missing += 1;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
const content = fs.readFileSync(surface.path, "utf8");
|
|
1247
|
+
const next = stripOpenSpecSurfaceOverlay(content);
|
|
1248
|
+
if (next === content) {
|
|
1249
|
+
counts.absent += 1;
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
counts.removed += 1;
|
|
1253
|
+
if (options.dryRun) {
|
|
1254
|
+
process.stdout.write(
|
|
1255
|
+
`keel: would remove OpenSpec ${surface.action} overlay from ${surface.path}\n`
|
|
1256
|
+
);
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
fs.writeFileSync(surface.path, next, "utf8");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Always reported, including `removed=0`: an uninstall that found nothing to
|
|
1263
|
+
// clean and an uninstall that never looked leave the same tree behind, and
|
|
1264
|
+
// the second one is the defect this exists to close.
|
|
1265
|
+
process.stdout.write(
|
|
1266
|
+
`keel: ${options.dryRun ? "would remove " : ""}OpenSpec ${overlayActionLabel()} `
|
|
1267
|
+
+ `overlay removed=${counts.removed} absent=${counts.absent} `
|
|
1268
|
+
+ `missing=${counts.missing}\n`
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
return { status: 0, ...counts };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1191
1274
|
function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
|
|
1192
1275
|
const surfaces = openspecOverlaySurfacesForTarget(target, repo);
|
|
1193
1276
|
const counts = {
|
|
@@ -1223,7 +1306,7 @@ function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
|
|
|
1223
1306
|
|
|
1224
1307
|
if (counts.refreshed > 0 || counts.current > 0) {
|
|
1225
1308
|
process.stdout.write(
|
|
1226
|
-
`keel: ${options.dryRun ? "would refresh " : ""}OpenSpec
|
|
1309
|
+
`keel: ${options.dryRun ? "would refresh " : ""}OpenSpec ${overlayActionLabel()} `
|
|
1227
1310
|
+ `overlay refreshed=${counts.refreshed} current=${counts.current} `
|
|
1228
1311
|
+ `missing=${counts.missing}\n`
|
|
1229
1312
|
);
|
|
@@ -1547,17 +1630,9 @@ function gitConfigHooksPath(repo) {
|
|
|
1547
1630
|
|
|
1548
1631
|
function printStandingAuthorizationSurface(repo) {
|
|
1549
1632
|
process.stdout.write("\nStanding authorization:\n");
|
|
1550
|
-
const { declared, unknown } = readStandingAuthorization(repo);
|
|
1633
|
+
const { declared, unknown, message } = readStandingAuthorization(repo);
|
|
1551
1634
|
if (unknown.length > 0) {
|
|
1552
|
-
printDoctorLine(
|
|
1553
|
-
"authorize",
|
|
1554
|
-
"failed",
|
|
1555
|
-
`keel/config.yaml declares unrecognized ${
|
|
1556
|
-
unknown.length === 1 ? "action" : "actions"
|
|
1557
|
-
}: ${unknown.join(", ")}; accepted names are `
|
|
1558
|
-
+ `${STANDING_AUTHORIZATION_ACTIONS.join(", ")}. The whole declaration `
|
|
1559
|
-
+ "authorizes nothing until it is corrected"
|
|
1560
|
-
);
|
|
1635
|
+
printDoctorLine("authorize", "failed", message);
|
|
1561
1636
|
return false;
|
|
1562
1637
|
}
|
|
1563
1638
|
printDoctorLine(
|
|
@@ -1578,12 +1653,31 @@ function printStandingAuthorizationSurface(repo) {
|
|
|
1578
1653
|
|
|
1579
1654
|
function printTriageSurface(repo) {
|
|
1580
1655
|
process.stdout.write("\nUnattended triage:\n");
|
|
1581
|
-
const { labels } = readTriagePolicy(repo);
|
|
1656
|
+
const { labels, issues, unreadable } = readTriagePolicy(repo);
|
|
1657
|
+
// An unreadable declaration is reported as broken rather than as absent. The
|
|
1658
|
+
// owner who typed it needs to know their file is the reason nothing runs,
|
|
1659
|
+
// which "undeclared" would tell them is not the case.
|
|
1660
|
+
if (unreadable.length > 0) {
|
|
1661
|
+
printDoctorLine(
|
|
1662
|
+
"triage",
|
|
1663
|
+
"unreadable",
|
|
1664
|
+
`keel/config.yaml declares triage entries Keel could not read — `
|
|
1665
|
+
+ `${unreadable.join(", ")}; no issue starts work unattended until the `
|
|
1666
|
+
+ "declaration is corrected"
|
|
1667
|
+
);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
// Only the declared sources are named. Naming a source with nothing under it
|
|
1671
|
+
// would read as a policy that exists, on the one surface asked to answer
|
|
1672
|
+
// "what may start work here" in a single line.
|
|
1673
|
+
const declared = [];
|
|
1674
|
+
if (labels.length > 0) declared.push(`issues labelled ${labels.join(", ")}`);
|
|
1675
|
+
if (issues.length > 0) declared.push(`issues numbered ${issues.join(", ")}`);
|
|
1582
1676
|
printDoctorLine(
|
|
1583
1677
|
"triage",
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
?
|
|
1678
|
+
declared.length > 0 ? "ok" : "none",
|
|
1679
|
+
declared.length > 0
|
|
1680
|
+
? `${declared.join(", and ")} may start work unattended; `
|
|
1587
1681
|
+ "admission decides nothing after it, and no declaration authorizes a merge"
|
|
1588
1682
|
: "undeclared; no issue starts work unattended"
|
|
1589
1683
|
);
|
|
@@ -1876,7 +1970,7 @@ function runAction(options) {
|
|
|
1876
1970
|
.split(",")
|
|
1877
1971
|
.map((label) => label.trim())
|
|
1878
1972
|
.filter(Boolean);
|
|
1879
|
-
const verdict = triageIssue(repo, labels);
|
|
1973
|
+
const verdict = triageIssue(repo, labels, options.issue);
|
|
1880
1974
|
const payload = {
|
|
1881
1975
|
schemaVersion: 1,
|
|
1882
1976
|
command: "triage",
|
|
@@ -2004,7 +2098,18 @@ function runAction(options) {
|
|
|
2004
2098
|
}
|
|
2005
2099
|
|
|
2006
2100
|
if (options.action === "clear" || options.action === "uninstall") {
|
|
2007
|
-
|
|
2101
|
+
const uninstallStatus = runPython(
|
|
2102
|
+
INSTALL_SCRIPT,
|
|
2103
|
+
installerArgs(options, ["--uninstall"])
|
|
2104
|
+
);
|
|
2105
|
+
if (uninstallStatus !== 0) {
|
|
2106
|
+
return uninstallStatus;
|
|
2107
|
+
}
|
|
2108
|
+
return removeOpenSpecSurfaceOverlay(
|
|
2109
|
+
path.resolve(options.repo || process.cwd()),
|
|
2110
|
+
options.target,
|
|
2111
|
+
{ dryRun: options.dryRun }
|
|
2112
|
+
).status;
|
|
2008
2113
|
}
|
|
2009
2114
|
|
|
2010
2115
|
if (options.action === "check") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.44.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.44.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",
|
|
@@ -73,24 +73,10 @@ write guard are untouched by anything in the store.
|
|
|
73
73
|
|
|
74
74
|
## Unattended runs
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
work is a materiality decision, and a precedent may not move one out of that list.
|
|
81
|
-
|
|
82
|
-
Admission answers "may this begin" and decides nothing after it. Alignment still escalates material
|
|
83
|
-
choices, the gates still run, and the write guard still binds.
|
|
84
|
-
|
|
85
|
-
An unattended run may triage, author, implement, verify, push where `authorize:` permits, and
|
|
86
|
-
**open a pull request**. It **may not merge** one — merging is where an unreviewed decision becomes
|
|
87
|
-
the project's history, and no declaration in Keel authorizes it.
|
|
88
|
-
|
|
89
|
-
Stopping at a decision the user must make is the **designed boundary rather than a failure**.
|
|
90
|
-
Report where the run stopped and why. Do not widen the triage policy to stop it happening.
|
|
91
|
-
|
|
92
|
-
**Keel schedules nothing.** `/loop`, cron, and CI triggers belong to the host runtime; Keel's part
|
|
93
|
-
is making each step decidable with authority.
|
|
76
|
+
The admission rule, what admission does and does not authorize, the open-a-pull-request/no-merge
|
|
77
|
+
boundary, and that Keel schedules nothing are stated once, in this repository's `AGENTS.md`, under
|
|
78
|
+
its own `## Unattended runs` section. Read the boundary there — this skill states no separate copy,
|
|
79
|
+
so the two surfaces cannot drift against each other.
|
|
94
80
|
|
|
95
81
|
## Domain lenses
|
|
96
82
|
|
|
@@ -23,7 +23,7 @@ Read the selected OpenSpec proposal, design, specs, tasks, diff, and command evi
|
|
|
23
23
|
- For a red-green strategy (`vertical-tdd`, `regression-first`), confirm concrete per-label `.red` and `.green` Evidence exists for the same check; evidence-first tasks instead name their observable proof.
|
|
24
24
|
- A failure message must **name the actual cause** of what it reports. Watch for one condition guarding **two distinct failures** — `if result is None or result["status"] != expected` reports the first failure's message when the second one happened, sending the reader to a place with no problem in it. Split the condition. No gate can judge this: deciding whether a sentence misleads needs a model, so it stays here.
|
|
25
25
|
- When two tasks in the change declared the same Touch set under a red-green strategy — `keel gate task-start` warns about this — ask whether they turned out to be **one behavior** split in half. The tell is that the first task's minimal implementation was wrong in the field, or that the second had no honest red left because the first already made its checks pass. The gate can only see the shape; by completion you can see the outcome, which is the only point at which this is answerable.
|
|
26
|
-
- Scope attribution is now the gate's job by default: `keel gate task-complete` compares the worktree against the dirty set `task-start` recorded and refuses a path outside Touch, so a `pass` is evidence about scope rather than silence about it. Two cases still need you. When the gate reports paths as *unattributed* — no recorded set, because the manifest predates the field or the guard was cleared — nothing was checked and the review is the only scope evidence. And a path already dirty when the task started
|
|
26
|
+
- Scope attribution is now the gate's job by default: `keel gate task-complete` compares the worktree against the dirty set `task-start` recorded and refuses a path outside Touch, so a `pass` is evidence about scope rather than silence about it. Two cases still need you. When the gate reports paths as *unattributed* — no recorded set, because the manifest predates the field or the guard was cleared — nothing was checked and the review is the only scope evidence. And a path already dirty when the task started stays unattributed only while its content matches what was recorded then; if the task changed it again, the gate attributes it — so read the gate's silence about such a path as evidence its content was never touched again, not as an unconditional exemption.
|
|
27
27
|
- For `Coupling: required`, confirm one complete candidate reached its completion gate and generated artifacts are aligned.
|
|
28
28
|
|
|
29
29
|
## Semantic Review
|
|
@@ -50,7 +50,7 @@ If any of these hold, stop and use the normal manual Keel loop.
|
|
|
50
50
|
4. Surface every command result and gate outcome in the transcript before any success claim.
|
|
51
51
|
5. Pass the current agent's Review, then `keel gate task-complete`.
|
|
52
52
|
6. The current agent durably checks the task checkbox.
|
|
53
|
-
7. Stop. Continuing to another task requires a new explicit user instruction and a new start fingerprint; there is no hidden scheduler or automatic next-task selection.
|
|
53
|
+
7. Stop. Continuing to another task requires a new explicit user instruction and a new start fingerprint; a standing `continuation` authorization in `keel/config.yaml` is the durable form of that instruction, covering only the next unchecked task of the same change in its approved `tasks.md` order. Each next task still passes `keel gate task-start` with its own recorded fingerprint, and there is still no hidden scheduler or automatic next-task selection — the declaration removes the confirmation, never the selection, the gates, or the evidence.
|
|
54
54
|
|
|
55
55
|
Resume reconstructs the goal from OpenSpec and Git only. Fingerprint drift, checkout divergence, a completed authorization, or missing authorization hard-stops before any product write; pass `--expected-fingerprint` and `--expected-owner` to assert the recorded authorization.
|
|
56
56
|
|
|
@@ -112,7 +112,7 @@ TASKS_COMMIT_STATUS_PATTERNS = (
|
|
|
112
112
|
"remove dirty/uncommitted state from tasks.md; keep durable work state in OpenSpec and use HANDOFF only as an explicit pointer override",
|
|
113
113
|
),
|
|
114
114
|
(
|
|
115
|
-
re.compile(r"(
|
|
115
|
+
re.compile(r"(?:未合入|待合入|已合入|合入\s*(?:master|main))"),
|
|
116
116
|
"remove commit or merge state from tasks.md; git log is the source of truth",
|
|
117
117
|
),
|
|
118
118
|
(
|
|
@@ -120,6 +120,28 @@ TASKS_COMMIT_STATUS_PATTERNS = (
|
|
|
120
120
|
"remove branch merge state from tasks.md; git log is the source of truth",
|
|
121
121
|
),
|
|
122
122
|
)
|
|
123
|
+
# 合入 names the git act and has no other reading, so it is refused on its own
|
|
124
|
+
# above. 提交 is an ordinary transitive verb — 提交资料, 提交审核, 提交申请 — and
|
|
125
|
+
# matching it bare refused an Evidence line about a third-party review queue
|
|
126
|
+
# while accepting the same sentence in English (#103). A check whose verdict
|
|
127
|
+
# depends on which language the author wrote in is not checking what it claims
|
|
128
|
+
# to, so this family is refused only where the line says the subject is git.
|
|
129
|
+
TASKS_SUBMISSION_STATE_RE = re.compile(r"(?:未提交|待提交|已提交|尚未提交)")
|
|
130
|
+
# The words that name git or a git object. Deliberately not `_HASH_CONTEXT_WORD`:
|
|
131
|
+
# that list holds 提交 and 合入 themselves, because its job is to let a state word
|
|
132
|
+
# supply context to a hash-shaped token, and reusing it here would let 已提交
|
|
133
|
+
# supply its own context and change nothing. The ASCII words carry boundaries for
|
|
134
|
+
# the reason #65 established; the Chinese ones cannot and do not.
|
|
135
|
+
TASKS_GIT_CONTEXT_RE = re.compile(
|
|
136
|
+
r"(?i)\b(?:git|commits?|committed|committing|master|main|HEAD|branch(?:es)?"
|
|
137
|
+
r"|PR|merges?|merged)\b"
|
|
138
|
+
r"|分支|仓库|代码库|代码|合入|工作区|暂存"
|
|
139
|
+
)
|
|
140
|
+
TASKS_SUBMISSION_MESSAGE = (
|
|
141
|
+
"remove commit or merge state from tasks.md; git log is the source of truth"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
123
145
|
# A commit identifier is hexadecimal, and `a`-`f` are the only reason those
|
|
124
146
|
# letters appear in one. A run of decimal digits alone is a phone number, a
|
|
125
147
|
# timestamp, an order number, or a port — evidence prose, not state git owns.
|
|
@@ -129,7 +151,22 @@ TASKS_COMMIT_STATUS_PATTERNS = (
|
|
|
129
151
|
# verbatim inside the lookahead, so the token that matches is the token that
|
|
130
152
|
# always matched, minus the all-decimal ones.
|
|
131
153
|
_HASH_SHAPED_TOKEN = r"\b(?=[0-9a-f]{7,40}\b)[0-9a-f]*[a-f][0-9a-f]*\b"
|
|
132
|
-
|
|
154
|
+
# A context word has to be a word. Matched anywhere on the line, `main` was
|
|
155
|
+
# supplied by `remaining`, `domain`, and `maintains`, and `head` by `heading`
|
|
156
|
+
# — and the tokens sitting beside those words in this repository's own
|
|
157
|
+
# history are `sha256:` contract anchors, not commit identifiers (#65).
|
|
158
|
+
# The inflections are spelled out rather than left to a prefix match: they are
|
|
159
|
+
# the words an author writes about the act this rule exists to catch, and the
|
|
160
|
+
# stricter list that drops `committed` and `hashes` would cost real refusals.
|
|
161
|
+
# The Chinese words carry no boundary because none is definable between two
|
|
162
|
+
# word characters — `\b提交\b` matches neither `已提交` nor `未提交`. What that
|
|
163
|
+
# buys is this rule: a state word beside a hash-shaped token still supplies the
|
|
164
|
+
# context that makes the token an identifier. The wording rule above no longer
|
|
165
|
+
# rests on it, because 提交 needs a git word of its own (#103).
|
|
166
|
+
_HASH_CONTEXT_WORD = (
|
|
167
|
+
r"(?:\b(?:commits?|committed|committing|master|main|HEAD|hash(?:es)?)\b"
|
|
168
|
+
r"|提交|合入|哈希)"
|
|
169
|
+
)
|
|
133
170
|
TASKS_CONTEXTUAL_HASH_RE = re.compile(
|
|
134
171
|
rf"(?i){_HASH_CONTEXT_WORD}.*{_HASH_SHAPED_TOKEN}|"
|
|
135
172
|
rf"{_HASH_SHAPED_TOKEN}.*{_HASH_CONTEXT_WORD}"
|
|
@@ -662,6 +699,66 @@ def is_tasks_rule_line(line: str) -> bool:
|
|
|
662
699
|
)
|
|
663
700
|
|
|
664
701
|
|
|
702
|
+
# The task contract compiler's own field bound, from `parseTasks()` in
|
|
703
|
+
# `src/core/task-contract.js`: a field starts at a two-space `- Name:` line and
|
|
704
|
+
# holds every line until the next one. Keeping the two readers on one rule is
|
|
705
|
+
# what stops a line being a Covers entry for the compiler and prose for this
|
|
706
|
+
# check.
|
|
707
|
+
TASKS_FIELD_LABEL_RE = re.compile(r"^ {2}- ([A-Za-z][A-Za-z /-]+):")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def covers_field_lines(content: str) -> set[int]:
|
|
711
|
+
"""1-based line numbers inside a `Covers` field.
|
|
712
|
+
|
|
713
|
+
A Covers entry is a citation — its segments must resolve to a requirement
|
|
714
|
+
or scenario that exists in a spec — so naming a requirement about dirty
|
|
715
|
+
worktrees or commit identifiers is not recording one. Read as prose, those
|
|
716
|
+
citations left an author no repair but to rename the requirement, which is
|
|
717
|
+
what 5.19.0 did and what #65 reports. Every citation is written on a line
|
|
718
|
+
below the label, so the exempt region is the field and not the label line.
|
|
719
|
+
"""
|
|
720
|
+
inside = False
|
|
721
|
+
lines: set[int] = set()
|
|
722
|
+
for number, line in enumerate(content.splitlines(), start=1):
|
|
723
|
+
label = TASKS_FIELD_LABEL_RE.match(line)
|
|
724
|
+
if label is not None:
|
|
725
|
+
inside = label.group(1) == "Covers"
|
|
726
|
+
if inside:
|
|
727
|
+
lines.add(number)
|
|
728
|
+
return lines
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# What an author quotes is content they cite, not a claim they make. Evidence
|
|
732
|
+
# prose quotes the command that ran, the output it printed, the branch base it
|
|
733
|
+
# ran against, and the name of the requirement under change — and every one of
|
|
734
|
+
# those carries exactly the words these rules refuse. `withoutInlineCode()` in
|
|
735
|
+
# `src/core/task-contract.js` settled this shape for the field reader; the two
|
|
736
|
+
# reports that reached this reader (#65 items 2 and 4) are the same shape
|
|
737
|
+
# arriving late.
|
|
738
|
+
#
|
|
739
|
+
# The ASCII single quote is deliberately absent. It is an apostrophe far more
|
|
740
|
+
# often than a quotation mark, and a span opened by `doesn't` would silence the
|
|
741
|
+
# remainder of the line — a rule that stops running without saying so, which is
|
|
742
|
+
# the failure no fixture catches by accident.
|
|
743
|
+
QUOTED_SPAN_RE = re.compile(
|
|
744
|
+
r"`[^`\n]*`"
|
|
745
|
+
r"|\"[^\"\n]*\""
|
|
746
|
+
r"|\u201c[^\u201d\n]*\u201d"
|
|
747
|
+
r"|\u300c[^\u300d\n]*\u300d"
|
|
748
|
+
r"|\u300a[^\u300b\n]*\u300b"
|
|
749
|
+
)
|
|
750
|
+
FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def without_quoted_spans(line: str) -> str:
|
|
754
|
+
"""Blank out the quoted spans of a line, leaving its length reading intact.
|
|
755
|
+
|
|
756
|
+
The replacement is a space rather than nothing so two tokens either side of
|
|
757
|
+
a span cannot be joined into a third word that neither of them was.
|
|
758
|
+
"""
|
|
759
|
+
return QUOTED_SPAN_RE.sub(" ", line)
|
|
760
|
+
|
|
761
|
+
|
|
665
762
|
def check_tasks_semantics(repo: Path) -> list[str]:
|
|
666
763
|
changes_root = repo / OPENSPEC_ROOT / "changes"
|
|
667
764
|
if not changes_root.is_dir():
|
|
@@ -682,12 +779,28 @@ def check_tasks_semantics(repo: Path) -> list[str]:
|
|
|
682
779
|
line = line_number_for_offset(content, match.start())
|
|
683
780
|
errors.append(f"{relative}:{line}: {message}")
|
|
684
781
|
|
|
782
|
+
cited = covers_field_lines(content)
|
|
783
|
+
fenced = False
|
|
685
784
|
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
686
|
-
|
|
785
|
+
# A fenced block is one long quoted span, and its own delimiter
|
|
786
|
+
# lines carry no prose. Tracking the state here rather than
|
|
787
|
+
# pre-stripping the file keeps every reported line number the
|
|
788
|
+
# line number the author sees.
|
|
789
|
+
if FENCE_RE.match(line):
|
|
790
|
+
fenced = not fenced
|
|
791
|
+
continue
|
|
792
|
+
if fenced or is_tasks_rule_line(line) or line_number in cited:
|
|
687
793
|
continue
|
|
794
|
+
line = without_quoted_spans(line)
|
|
688
795
|
for pattern, message in TASKS_COMMIT_STATUS_PATTERNS:
|
|
689
796
|
if pattern.search(line):
|
|
690
797
|
errors.append(f"{relative}:{line_number}: {message}")
|
|
798
|
+
if TASKS_SUBMISSION_STATE_RE.search(line) and TASKS_GIT_CONTEXT_RE.search(
|
|
799
|
+
line
|
|
800
|
+
):
|
|
801
|
+
errors.append(
|
|
802
|
+
f"{relative}:{line_number}: {TASKS_SUBMISSION_MESSAGE}"
|
|
803
|
+
)
|
|
691
804
|
if TASKS_CONTEXTUAL_HASH_RE.search(line):
|
|
692
805
|
errors.append(
|
|
693
806
|
f"{relative}:{line_number}: remove contextual commit hash from tasks.md; git log is the source of truth"
|