@kontourai/flow-agents 3.1.0 → 3.2.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.
Files changed (52) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +17 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +97 -12
  5. package/build/src/cli/workflow-sidecar.d.ts +14 -4
  6. package/build/src/cli/workflow-sidecar.js +100 -10
  7. package/context/contracts/assignment-provider-contract.md +1 -1
  8. package/context/contracts/execution-contract.md +78 -0
  9. package/context/scripts/hooks/config-protection.js +11 -4
  10. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  11. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  12. package/evals/ci/run-baseline.sh +2 -0
  13. package/evals/integration/test_checkpoint_signing.sh +4 -3
  14. package/evals/integration/test_gate_lockdown.sh +36 -0
  15. package/evals/integration/test_model_routing_escalation.sh +145 -0
  16. package/evals/integration/test_publish_delivery.sh +14 -6
  17. package/evals/integration/test_stop_hook_release.sh +552 -0
  18. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  19. package/evals/run.sh +6 -0
  20. package/evals/static/test_model_routing_hints.sh +107 -0
  21. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  22. package/kits/builder/skills/deliver/SKILL.md +52 -11
  23. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  24. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  25. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  26. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  27. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  28. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  29. package/kits/builder/skills/review-work/SKILL.md +11 -0
  30. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  31. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  32. package/kits/knowledge/adapters/default-store/index.js +56 -15
  33. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  34. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  35. package/kits/knowledge/adapters/shared/codec.js +124 -0
  36. package/kits/knowledge/docs/store-contract.md +405 -3
  37. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  38. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  39. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  40. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  41. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  42. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  43. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  44. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  45. package/package.json +1 -1
  46. package/schemas/workflow-handoff.schema.json +6 -0
  47. package/scripts/ci/mint-attestation.js +33 -6
  48. package/scripts/ci/trust-reconcile.js +144 -26
  49. package/scripts/hooks/config-protection.js +11 -4
  50. package/scripts/hooks/stop-goal-fit.js +259 -4
  51. package/src/cli/assignment-provider.ts +110 -12
  52. package/src/cli/workflow-sidecar.ts +99 -10
@@ -26,6 +26,10 @@ import {
26
26
  loadAliasIndex,
27
27
  saveAliasIndex,
28
28
  registerAliases,
29
+ // Record-carried freshness + derived staleness (issue #341, Addendum J).
30
+ freshnessPatch,
31
+ decodeFreshnessFields,
32
+ isRecordStale,
29
33
  } from "../shared/codec.js";
30
34
 
31
35
  // ---------------------------------------------------------------------------
@@ -152,7 +156,17 @@ function parseYamlLines(lines, start, baseIndent) {
152
156
  }
153
157
 
154
158
  function unquote(s) {
155
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
159
+ if (s.startsWith('"') && s.endsWith('"')) {
160
+ // Double-quoted scalars are escaped by yamlScalar; reverse those escapes
161
+ // (backslash, quote, newline, carriage-return, tab) left-to-right so a
162
+ // frontmatter value can carry newlines/quotes and round-trip intact. This
163
+ // keeps every scalar on ONE physical line, so an embedded "\n---\n" can
164
+ // never be mistaken for the frontmatter/body terminator (parseMarkdown).
165
+ return s.slice(1, -1).replace(/\\(["\\nrt])/g, (_, c) =>
166
+ c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c
167
+ );
168
+ }
169
+ if (s.startsWith("'") && s.endsWith("'")) {
156
170
  return s.slice(1, -1);
157
171
  }
158
172
  return s;
@@ -210,9 +224,20 @@ function serializeYaml(obj, indent = 0) {
210
224
 
211
225
  function yamlScalar(v) {
212
226
  if (typeof v === "string") {
213
- // Quote if it contains special chars
214
- if (/[:#\[\]{},&*?|<>=!%@`"'\n]/.test(v) || v.trim() !== v || v === "") {
215
- return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
227
+ // Quote if it contains special chars (incl. any whitespace control char that
228
+ // would otherwise span lines newline, carriage-return, tab).
229
+ if (/[:#\[\]{},&*?|<>=!%@`"'\n\r\t]/.test(v) || v.trim() !== v || v === "") {
230
+ // Escape backslash first, then quote and the control chars, so the value
231
+ // stays on ONE physical line. Reversed by unquote(). Without newline
232
+ // escaping a value like a multi-section proposal body would inject a raw
233
+ // "\n---\n" into the frontmatter and corrupt the record on read.
234
+ const escaped = v
235
+ .replace(/\\/g, "\\\\")
236
+ .replace(/"/g, '\\"')
237
+ .replace(/\n/g, "\\n")
238
+ .replace(/\r/g, "\\r")
239
+ .replace(/\t/g, "\\t");
240
+ return `"${escaped}"`;
216
241
  }
217
242
  return v;
218
243
  }
@@ -398,7 +423,8 @@ export class DefaultKnowledgeStore {
398
423
  if (!fs.existsSync(p)) return null;
399
424
  const text = fs.readFileSync(p, "utf8");
400
425
  const { meta, body } = parseMarkdown(text);
401
- return { ...meta, body };
426
+ // Coerce record-carried freshness fields to canonical types (#341).
427
+ return decodeFreshnessFields({ ...meta, body });
402
428
  }
403
429
 
404
430
  _writeRecord(record) {
@@ -444,6 +470,10 @@ export class DefaultKnowledgeStore {
444
470
  registerAliases(aliasIndex, id, aliases);
445
471
  }
446
472
 
473
+ // Validate optional freshness fields (#341) before any write, so a malformed
474
+ // expires_at / ttl_seconds aborts create without a partial record.
475
+ const fresh = freshnessPatch(input);
476
+
447
477
  // Merge explicit links + wikilinks from body
448
478
  const explicitLinks = input.links || [];
449
479
  const wikilinks = extractWikilinks(input.body || "");
@@ -459,6 +489,7 @@ export class DefaultKnowledgeStore {
459
489
  status: "active",
460
490
  created_at: now,
461
491
  updated_at: now,
492
+ ...fresh,
462
493
  provenance: {
463
494
  agent: input.provenance.agent,
464
495
  ...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
@@ -494,7 +525,7 @@ export class DefaultKnowledgeStore {
494
525
  const record = this._readRecord(id);
495
526
  if (!record) throw notFoundError(id);
496
527
 
497
- const mutableKeys = ["title", "body", "category", "tags", "links", "aliases"];
528
+ const mutableKeys = ["title", "body", "category", "tags", "links", "aliases", "expires_at", "ttl_seconds"];
498
529
  const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
499
530
  if (supplied.length === 0)
500
531
  throw missingEvidenceError("update: at least one mutable field must be supplied");
@@ -502,6 +533,10 @@ export class DefaultKnowledgeStore {
502
533
  if (fields.category !== undefined && !validateCategory(fields.category))
503
534
  throw missingEvidenceError(`update: invalid category: ${fields.category}`);
504
535
 
536
+ // Validate/normalize supplied freshness fields (#341). A field set to null/""
537
+ // clears it (patch value undefined → dropped by the serializer).
538
+ const fresh = freshnessPatch(fields);
539
+
505
540
  const now = this._now();
506
541
 
507
542
  // Slug aliases are append-only: supplied aliases are UNIONED with existing
@@ -533,6 +568,7 @@ export class DefaultKnowledgeStore {
533
568
  ...(fields.category !== undefined ? { category: fields.category } : {}),
534
569
  ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
535
570
  ...(mergedAliases.length ? { aliases: mergedAliases } : {}),
571
+ ...fresh,
536
572
  links: newLinks,
537
573
  updated_at: now,
538
574
  mutation_log: [
@@ -943,18 +979,19 @@ export class DefaultKnowledgeStore {
943
979
  async listByCategory(category, options = {}) {
944
980
  const records = this._allRecords();
945
981
  const includeRetired = options.includeRetired === true;
982
+ // Derived staleness filter (#341): keep only records past their own effective
983
+ // expiry at `now` (injectable for tests). Absent → no staleness filtering.
984
+ const staleOnly = options.stale === true;
985
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
986
+ const keep = (r) =>
987
+ (includeRetired || (r.status || "active") !== "retired") &&
988
+ (!staleOnly || isRecordStale(r, nowMs));
946
989
  if (options.prefix) {
947
990
  return records.filter(
948
- (r) =>
949
- (r.category === category || r.category.startsWith(`${category}.`)) &&
950
- (includeRetired || (r.status || "active") !== "retired")
991
+ (r) => (r.category === category || r.category.startsWith(`${category}.`)) && keep(r)
951
992
  );
952
993
  }
953
- return records.filter(
954
- (r) =>
955
- r.category === category &&
956
- (includeRetired || (r.status || "active") !== "retired")
957
- );
994
+ return records.filter((r) => r.category === category && keep(r));
958
995
  }
959
996
 
960
997
  // -------------------------------------------------------------------------
@@ -963,10 +1000,14 @@ export class DefaultKnowledgeStore {
963
1000
 
964
1001
  async listByType(type, options = {}) {
965
1002
  const includeRetired = options.includeRetired === true;
1003
+ // Derived staleness filter (#341) — see listByCategory.
1004
+ const staleOnly = options.stale === true;
1005
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
966
1006
  return this._allRecords().filter(
967
1007
  (r) =>
968
1008
  r.type === type &&
969
- (includeRetired || (r.status || "active") !== "retired")
1009
+ (includeRetired || (r.status || "active") !== "retired") &&
1010
+ (!staleOnly || isRecordStale(r, nowMs))
970
1011
  );
971
1012
  }
972
1013