@christang/keel 5.39.0 → 5.46.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.
@@ -7,7 +7,13 @@ const path = require("path");
7
7
  // closed so an entry outside it can be reported by name: a free-form grant
8
8
  // cannot tell a typo from a decision, and silently dropping one leaves the
9
9
  // author believing they authorized something they did not.
10
- const STANDING_AUTHORIZATION_ACTIONS = ["commit", "push", "release", "archive"];
10
+ const STANDING_AUTHORIZATION_ACTIONS = [
11
+ "commit",
12
+ "push",
13
+ "release",
14
+ "archive",
15
+ "continuation",
16
+ ];
11
17
 
12
18
  // The closed vocabulary of capability tiers a repository may declare for a
13
19
  // delegated task. The names describe the capability the work requires, never
package/src/core/gates.js CHANGED
@@ -244,7 +244,7 @@ function taskStart(repo, options) {
244
244
  const compiled = compileTaskContract(repo, selection.change, task);
245
245
  const problems = [
246
246
  ...compiled.diagnostics,
247
- ...invalidationProblems(repo, selection.content, selection.tasks),
247
+ ...invalidationProblems(repo, selection.content, selection.tasks, selection.change),
248
248
  ];
249
249
  // Recording the current fingerprint is idempotent: --record replaces the
250
250
  // selected task's Contract anchor whatever it holds, so reauthorizing a task
@@ -394,11 +394,21 @@ const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
394
394
 
395
395
  // The owner forms, stated once so every refusal that lists them agrees with
396
396
  // every other and with what the checks below actually accept.
397
+ //
398
+ // What each form is worth is part of the sentence, because a list of accepted
399
+ // spellings reads as a list of verified guarantees. A path is checked for
400
+ // existence at the moment it is cited and never again; a tracker reference is
401
+ // accepted on its shape, because a gate that fetched one would stop being
402
+ // local and offline, which is the property its verdict rests on. Leaving that
403
+ // unsaid is how an author comes to believe a check ran that did not (#100).
397
404
  const DURABLE_OWNER_FORMS =
398
405
  "an absolute `https://…` tracker reference, or any repo-relative path that "
399
- + "exists — `keel/archive/…`, an `openspec/changes/…` artifact, or the "
400
- + "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
401
- + "than an owner";
406
+ + "exists and outlives this change — an archived `openspec/changes/archive/…` "
407
+ + "artifact, `keel/archive/…`, or the repository's own ledger; "
408
+ + "`keel/HANDOFF.md` is a pointer override rather than an owner. A path is "
409
+ + "checked for existence when it is cited and is not re-checked afterwards, "
410
+ + "and a tracker reference is accepted on its shape because a gate runs "
411
+ + "offline and never fetches one";
402
412
 
403
413
  // Trailing punctuation a declared path can abut in prose. ASCII sentence marks
404
414
  // and their CJK counterparts both belong here: once the extractor stops
@@ -407,9 +417,23 @@ const DURABLE_OWNER_FORMS =
407
417
  // swallows it and the gate looks for a file that cannot exist.
408
418
  const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
409
419
 
410
- // A declared path is a run of non-whitespace holding a separator. What ends a
411
- // path is whitespace; what a path is *made of* is the filesystem's business,
412
- // and answering the first question with the second is what refused
420
+ // A file at the repository root carries no separator, and nine of them sit at
421
+ // this repository's root — `AGENTS.md`, `README.md`, `package.json` among them
422
+ // — every one a legitimate owner. Requiring a separator refused them with "it
423
+ // names neither a check nor a path", for a path whose file exists, and left
424
+ // the author only `./AGENTS.md`: a concession to this function rather than a
425
+ // path anyone meant (issue #107).
426
+ //
427
+ // The shape is a trailing extension beginning with a letter. That is what
428
+ // keeps a bare word unrecognized — `Durable owner: pending` reported as a
429
+ // missing file would send the author to create one — and what keeps a version
430
+ // string out, since `5.44.0` would otherwise read as `44` with extension `0`,
431
+ // and authors write versions in prose beside an owner.
432
+ const ROOT_FILE_NAME = /^[^\s`]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
433
+
434
+ // A declared path is a run of non-whitespace. What ends a path is whitespace;
435
+ // what a path is *made of* is the filesystem's business, and answering the
436
+ // first question with the second is what refused
413
437
  // `notes/note-006-转岗最难的不是流程/note.md` by reporting that
414
438
  // `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
415
439
  // same class as #40 on the worktree-reading side, which survived because that
@@ -425,24 +449,66 @@ function declaredPath(value) {
425
449
  const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
426
450
  if (quoted) return quoted[1].trim() || null;
427
451
  const bare = text.match(/[^\s`]+\/[^\s`]+/);
428
- if (!bare) return null;
429
- return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
452
+ if (bare) return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
453
+ // The separator form is tried first and is unchanged, so nothing that
454
+ // resolves today resolves differently. The trim runs before the shape is
455
+ // judged, so a root file ending a sentence is still a root file.
456
+ for (const token of text.split(/\s+/)) {
457
+ const trimmed = token.replace(DECLARED_PATH_TRAILING, "");
458
+ if (trimmed && ROOT_FILE_NAME.test(trimmed)) return trimmed;
459
+ }
460
+ return null;
461
+ }
462
+
463
+ // A path inside the selected change's own directory exists now and cannot
464
+ // exist later: archiving moves `openspec/changes/<name>/` under
465
+ // `openspec/changes/archive/`, so the one guarantee the gate offers expires in
466
+ // the next step of the workflow that accepted it. Measured in this repository,
467
+ // 10 declarations name such a path and all 10 are dead; the field report
468
+ // measured 35 of 36 (issue #100). Existence is necessary and not sufficient —
469
+ // the same line `keel/HANDOFF.md` already sits on.
470
+ //
471
+ // The rule is the directory, not the file: every file under it moves together,
472
+ // and naming `design.md` would refuse one spelling of one instance. And it is
473
+ // *this* change's directory, not change directories in general — the protocol
474
+ // names a new OpenSpec change as a legitimate owner of deferred work, and no
475
+ // measured pointer has that shape.
476
+ function insideOwnChangeDirectory(candidate, change) {
477
+ if (!change || !candidate) return false;
478
+ const prefix = `openspec/changes/${change}/`;
479
+ return String(candidate).replace(/^\.\//, "").startsWith(prefix);
430
480
  }
431
481
 
432
482
  // Classify a declared `Durable owner:` value. A gate runs without network, so a
433
483
  // URL is accepted on shape alone; a path is the one form it can actually check,
434
- // and checking it is stricter than the prefix whitelist this replaced.
435
- function durableOwnerVerdict(repo, value) {
484
+ // and checking it is stricter than the prefix whitelist this replaced — for as
485
+ // long as the path outlives the change, which `insideOwnChangeDirectory` is
486
+ // there to decide.
487
+ function durableOwnerVerdict(repo, value, change) {
436
488
  const owner = String(value || "").trim();
437
489
  if (!owner) return { ok: false, reason: "unrecognized" };
438
490
  if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
439
491
  if (TRACKER_REFERENCE.test(owner)) return { ok: true };
440
492
  const candidate = declaredPath(owner);
441
493
  if (!candidate) return { ok: false, reason: "unrecognized" };
494
+ if (insideOwnChangeDirectory(candidate, change)) {
495
+ return { ok: false, reason: "transient", path: candidate };
496
+ }
442
497
  if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
443
498
  return { ok: false, reason: "missing", path: candidate };
444
499
  }
445
500
 
501
+ // The one sentence every transient refusal makes, so the three consumers say
502
+ // it the same way. It names the cause (the directory moves) rather than the
503
+ // symptom, because the author cannot repair a spelling problem they do not
504
+ // have.
505
+ function transientOwnerMessage(candidate) {
506
+ return `\`${candidate}\` is inside this change's own directory, which moves `
507
+ + "to `openspec/changes/archive/` when the change is archived — the file "
508
+ + "exists now and the pointer is guaranteed to break. Name something that "
509
+ + `outlives the change: ${DURABLE_OWNER_FORMS}.`;
510
+ }
511
+
446
512
  // A finding has three dispositions and the gate recognized two. One found and
447
513
  // fixed inside the task recording it has no owner to name and nothing to
448
514
  // discard, so the only text that passed was `Discard reason:` — filing a repair
@@ -469,7 +535,7 @@ const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
469
535
  // it or the artifact that shows it. A bare marker is refused because a
470
536
  // disposition that asserts its own conclusion would be a way out of the other
471
537
  // two, and the third state would decay into the easiest exit.
472
- function resolutionEvidenceVerdict(repo, value, commands) {
538
+ function resolutionEvidenceVerdict(repo, value, commands, change) {
473
539
  const evidence = String(value || "").trim();
474
540
  if (!evidence) return { ok: false, reason: "empty" };
475
541
  // The tracker form is tested before the path form: a URL contains something
@@ -484,6 +550,12 @@ function resolutionEvidenceVerdict(repo, value, commands) {
484
550
  }
485
551
  const candidate = declaredPath(evidence);
486
552
  if (!candidate) return { ok: false, reason: "unrecognized" };
553
+ // Resolution evidence is a file like any other and moves with the directory
554
+ // holding it, so it earns the same verdict rather than a second answer to
555
+ // the same question.
556
+ if (insideOwnChangeDirectory(candidate, change)) {
557
+ return { ok: false, reason: "transient", path: candidate };
558
+ }
487
559
  if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
488
560
  return { ok: false, reason: "missing", path: candidate };
489
561
  }
@@ -504,13 +576,16 @@ function resolutionEvidenceMessage(verdict) {
504
576
  if (verdict.reason === "unknown-check") {
505
577
  return `${lead}${verdict.label} is not a check this task declares.${tail}`;
506
578
  }
579
+ if (verdict.reason === "transient") {
580
+ return `${lead}${transientOwnerMessage(verdict.path)}${tail}`;
581
+ }
507
582
  if (verdict.reason === "missing") {
508
583
  return `${lead}\`${verdict.path}\` does not exist.${tail}`;
509
584
  }
510
585
  return `${lead}it names neither a check nor a path.${tail}`;
511
586
  }
512
587
 
513
- function findingOwnerIsDurable(repo, findings) {
588
+ function findingOwnerIsDurable(repo, findings, change) {
514
589
  if (/keel\/HANDOFF\.md/i.test(findings)) return false;
515
590
  if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
516
591
  return true;
@@ -520,11 +595,17 @@ function findingOwnerIsDurable(repo, findings) {
520
595
  // prose, and a finding that merely mentions the source file it concerns has
521
596
  // not thereby given that finding an owner.
522
597
  const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
523
- if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
598
+ if (declared) return durableOwnerVerdict(repo, declared[1], change).ok;
524
599
  const artifact = findings.match(
525
600
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
526
601
  );
527
- if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
602
+ if (
603
+ artifact
604
+ && !insideOwnChangeDirectory(artifact[1], change)
605
+ && fs.existsSync(path.join(repo, artifact[1]))
606
+ ) {
607
+ return true;
608
+ }
528
609
  // Same extractor, scoped to the archive prefix: the segment after
529
610
  // `keel/archive/` is a path like any other and was equally ASCII-bound.
530
611
  const archive = findings.match(/keel\/archive\/[^\s`]*/i);
@@ -741,7 +822,7 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
741
822
  };
742
823
  }
743
824
 
744
- function completionChecks(repo, task, contract = null, changeVerify = null) {
825
+ function completionChecks(repo, task, contract = null, changeVerify = null, change = null) {
745
826
  const problems = [];
746
827
  const commands = contract
747
828
  ? contract.capsule.verification.commands.map((item) => item.label)
@@ -900,14 +981,14 @@ function completionChecks(repo, task, contract = null, changeVerify = null) {
900
981
  const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
901
982
  if (resolved.length > 0) {
902
983
  for (const claim of resolved) {
903
- const verdict = resolutionEvidenceVerdict(repo, claim[1], commands);
984
+ const verdict = resolutionEvidenceVerdict(repo, claim[1], commands, change);
904
985
  if (verdict.ok) continue;
905
986
  problems.push(
906
987
  problem("finding-resolution-evidence", resolutionEvidenceMessage(verdict))
907
988
  );
908
989
  break;
909
990
  }
910
- } else if (!findingOwnerIsDurable(repo, reviewFields.Findings)) {
991
+ } else if (!findingOwnerIsDurable(repo, reviewFields.Findings, change)) {
911
992
  problems.push(
912
993
  problem(
913
994
  "finding-owner",
@@ -950,7 +1031,13 @@ function taskComplete(repo, options) {
950
1031
  const contract = compileTaskContract(repo, selection.change, task);
951
1032
  const usableContract = contract.diagnostics.length === 0 ? contract : null;
952
1033
  const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
953
- const checks = completionChecks(repo, task, usableContract, changeVerify);
1034
+ const checks = completionChecks(
1035
+ repo,
1036
+ task,
1037
+ usableContract,
1038
+ changeVerify,
1039
+ selection.change
1040
+ );
954
1041
  checks.problems.push(...contract.diagnostics);
955
1042
  const missingAnchor = missingAnchorProblem(selection, task);
956
1043
  if (missingAnchor) {
@@ -1125,7 +1212,7 @@ function changeVerifyProblems(content, tasks) {
1125
1212
  // the author was not already holding in mind, so a list of remembered files
1126
1213
  // reproduces the failure; a searchable phrase is what turns the declaration
1127
1214
  // into a grep. What the phrase says is the agent's judgment, not the gate's.
1128
- function invalidationProblems(repo, content, tasks) {
1215
+ function invalidationProblems(repo, content, tasks, change) {
1129
1216
  const heading = content.search(/^## Invalidates\s*$/m);
1130
1217
  if (heading < 0) {
1131
1218
  return [
@@ -1159,7 +1246,15 @@ function invalidationProblems(repo, content, tasks) {
1159
1246
  const problems = [];
1160
1247
  for (const entry of entries) {
1161
1248
  const [, id, body] = entry;
1162
- if (!/"[^"\n]{3,}"/.test(body)) {
1249
+ // The quotation is read across the entry, not one line of it. An entry
1250
+ // carries a quotation, a location, and a closure, and wraps as often as it
1251
+ // needs to — 42 of this repository's 194 archived entries span more than
1252
+ // one line. Requiring the quotation to fit on one refused entries that had
1253
+ // named exactly what was asked for, and offered no repair but reflowing
1254
+ // the text (issue #108). The bound is the entry: `entries` above splits on
1255
+ // the next `I<n>`, so a quotation cannot reach past its own. `Findings` in
1256
+ // this same file is already read as wrapping.
1257
+ if (!/"[^"]{3,}"/.test(body)) {
1163
1258
  problems.push(
1164
1259
  problem(
1165
1260
  "invalidation-phrase",
@@ -1173,7 +1268,7 @@ function invalidationProblems(repo, content, tasks) {
1173
1268
  const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
1174
1269
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
1175
1270
  const verdict = declaredOwner
1176
- ? durableOwnerVerdict(repo, declaredOwner[1])
1271
+ ? durableOwnerVerdict(repo, declaredOwner[1], change)
1177
1272
  : { ok: false, reason: "absent" };
1178
1273
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
1179
1274
  if (!updated && !verdict.ok && !discarded) {
@@ -1185,6 +1280,13 @@ function invalidationProblems(repo, content, tasks) {
1185
1280
  + "file exists in this repository."
1186
1281
  )
1187
1282
  );
1283
+ } else if (verdict.reason === "transient") {
1284
+ problems.push(
1285
+ problem(
1286
+ "invalidation-owner-transient",
1287
+ `${id} names ${transientOwnerMessage(verdict.path)}`
1288
+ )
1289
+ );
1188
1290
  } else if (verdict.reason === "handoff") {
1189
1291
  problems.push(
1190
1292
  problem(
@@ -1227,7 +1329,7 @@ function invalidationProblems(repo, content, tasks) {
1227
1329
  return problems;
1228
1330
  }
1229
1331
 
1230
- function expectationProblems(repo, content, tasks) {
1332
+ function expectationProblems(repo, content, tasks, change) {
1231
1333
  const heading = content.search(/^## Expectation Coverage\s*$/m);
1232
1334
  if (heading < 0) {
1233
1335
  return [
@@ -1263,7 +1365,7 @@ function expectationProblems(repo, content, tasks) {
1263
1365
  const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
1264
1366
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
1265
1367
  const verdict = declaredOwner
1266
- ? durableOwnerVerdict(repo, declaredOwner[1])
1368
+ ? durableOwnerVerdict(repo, declaredOwner[1], change)
1267
1369
  : { ok: false, reason: "absent" };
1268
1370
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
1269
1371
  if (!covered && !verdict.ok && !discarded) {
@@ -1275,6 +1377,13 @@ function expectationProblems(repo, content, tasks) {
1275
1377
  + "file exists in this repository."
1276
1378
  )
1277
1379
  );
1380
+ } else if (verdict.reason === "transient") {
1381
+ problems.push(
1382
+ problem(
1383
+ "expectation-owner-transient",
1384
+ `${id} names ${transientOwnerMessage(verdict.path)}`
1385
+ )
1386
+ );
1278
1387
  } else {
1279
1388
  problems.push(
1280
1389
  problem(
@@ -1378,7 +1487,8 @@ function changeClose(repo, options) {
1378
1487
  repo,
1379
1488
  task,
1380
1489
  contract.diagnostics.length === 0 ? contract : null,
1381
- changeVerify
1490
+ changeVerify,
1491
+ selection.change
1382
1492
  );
1383
1493
  problems.push(
1384
1494
  ...checks.problems.map((item) =>
@@ -1391,7 +1501,9 @@ function changeClose(repo, options) {
1391
1501
  )
1392
1502
  );
1393
1503
  }
1394
- problems.push(...expectationProblems(repo, selection.content, selection.tasks));
1504
+ problems.push(
1505
+ ...expectationProblems(repo, selection.content, selection.tasks, selection.change)
1506
+ );
1395
1507
  problems.push(...changeVerifyProblems(selection.content, selection.tasks));
1396
1508
 
1397
1509
  const changePath = path.dirname(selection.tasksPath);
@@ -687,9 +687,16 @@ function criticalAuthority(repo, change, reference) {
687
687
  };
688
688
  }
689
689
  const content = fs.readFileSync(designPath, "utf8");
690
+ // Accepted line shapes: an optional CommonMark list bullet, the identifier
691
+ // bare or wrapped in balanced `**`, then the dash and statement. Authors
692
+ // overwhelmingly write the bulleted and bold shapes (issue #49).
690
693
  const matches = [
691
694
  ...content.matchAll(
692
- new RegExp(`^\\s*${reference}\\s*[—-]\\s*(.+?)\\s*$`, "gmi")
695
+ new RegExp(
696
+ `^\\s*(?:[-*+]\\s+)?(?:\\*\\*${reference}\\*\\*|${reference})`
697
+ + `\\s*[—-]\\s*(.+?)\\s*$`,
698
+ "gmi"
699
+ )
693
700
  ),
694
701
  ];
695
702
  if (matches.length !== 1) {
@@ -707,9 +714,11 @@ function criticalAuthority(repo, change, reference) {
707
714
  code: "unresolved-covers",
708
715
  message:
709
716
  `Unparsed Covers critical statement: ${reference}. It appears in `
710
- + "design.md but not in the required shape — write it starting "
711
- + `the line as \`${reference} — one-line statement\` (no leading `
712
- + "`-`, `**`, or other decoration) so it can be resolved.",
717
+ + "design.md but not in an accepted line shape — write it as a "
718
+ + "line opening with the identifier and a dash, "
719
+ + `\`${reference} — one-line statement\`, optionally as a list `
720
+ + `bullet (\`- ${reference} — …\`) and/or with the identifier `
721
+ + `bold (\`**${reference}** — …\`).`,
713
722
  },
714
723
  };
715
724
  }
@@ -758,7 +767,17 @@ function resolveAuthority(repo, change, task) {
758
767
  }
759
768
  const entries = [...seen].sort();
760
769
  for (const entry of entries) {
761
- const critical = criticalAuthority(repo, change, entry);
770
+ // A critical-statement reference may open its entry with a trailing
771
+ // annotation after a dash (`D2 — note`); the identifier resolves and the
772
+ // annotation stays annotation — design.md owns the statement text. The
773
+ // boundary after the identifier is whitespace or an em dash so that free
774
+ // text like `D2-compatible` does not become a reference.
775
+ const annotated = entry.match(/^([DFAQ]\d+)(?=\s|—)\s*[—-]\s*.+$/);
776
+ const critical = criticalAuthority(
777
+ repo,
778
+ change,
779
+ annotated ? annotated[1] : entry
780
+ );
762
781
  if (critical) {
763
782
  if (critical.diagnostic) diagnostics.push(critical.diagnostic);
764
783
  if (critical.authority) authority.push(critical.authority);