syntaur 0.65.0 → 0.66.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syntaur",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "Syntaur protocol skills for AI coding agents — assignment, project, plan, and session lifecycle. Cross-agent (Claude Code, Codex, Cursor, OpenCode, Gemini CLI, and more via skills.sh).",
5
5
  "author": {
6
6
  "name": "Brennen",
@@ -470,7 +470,7 @@ function formatYamlValue(value) {
470
470
  if (/^(null|~|true|false|-?\d+(\.\d+)?)$/i.test(value)) {
471
471
  return `"${value}"`;
472
472
  }
473
- if (/[:#{}[\],&*?|>!%@\`]/.test(value) || /^\s|\s$/.test(value) || value === "") {
473
+ if (/[:#{}[\],&*?|>!%@\`]/.test(value) || /^\s|\s$/.test(value) || /^["']|["']$/.test(value) || value === "") {
474
474
  const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
475
475
  return `"${escaped}"`;
476
476
  }
@@ -497,7 +497,7 @@ ${key}: ${formatted}${result.slice(closeIdx)}`;
497
497
  function findWorkspaceBlock(fmBlock) {
498
498
  const headerMatch = fmBlock.match(/^workspace:\s*$/m);
499
499
  if (!headerMatch) return null;
500
- const headerStart = fmBlock.indexOf(headerMatch[0]);
500
+ const headerStart = headerMatch.index ?? fmBlock.indexOf(headerMatch[0]);
501
501
  const bodyStart = headerStart + headerMatch[0].length + 1;
502
502
  const after = fmBlock.slice(bodyStart);
503
503
  const lines = after.split("\n");
@@ -555,7 +555,7 @@ function renameStatusInHistory(content, oldId, newId) {
555
555
  if (!fmMatch) return content;
556
556
  const esc = oldId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
557
557
  const re = new RegExp(`^(\\s+(?:from|to|phaseFrom|phaseTo):[ \\t]*)("?)${esc}\\2[ \\t]*$`, "gm");
558
- const newFm = fmMatch[2].replace(re, (_m, prefix, quote) => `${prefix}${quote}${newId}${quote}`);
558
+ const newFm = fmMatch[2].replace(re, (_m, prefix) => `${prefix}${formatYamlValue(newId)}`);
559
559
  return `${fmMatch[1]}${newFm}${fmMatch[3]}${content.slice(fmMatch[0].length)}`;
560
560
  }
561
561
  function findStatusHistoryBlock(fmBlock) {
@@ -1300,7 +1300,9 @@ function parseDecisionRecord(fileContent) {
1300
1300
  function parseComments(fileContent) {
1301
1301
  const [fm, body] = extractFrontmatter2(fileContent);
1302
1302
  const entries = [];
1303
- const sections = body.split(/^## /m).slice(1);
1303
+ const sections = body.split(
1304
+ /^## (?=[^\n]*\n\s*\*\*Recorded:\*\*[^\n]*\n\*\*Author:\*\*[^\n]*\n\*\*Type:\*\*\s*(?:question|note|feedback)\b)/m
1305
+ ).slice(1);
1304
1306
  for (const section of sections) {
1305
1307
  const newlineIdx = section.indexOf("\n");
1306
1308
  if (newlineIdx === -1) continue;
@@ -1403,12 +1405,14 @@ var parser_exports = {};
1403
1405
  __export(parser_exports, {
1404
1406
  appendLogEntry: () => appendLogEntry,
1405
1407
  archivePath: () => archivePath,
1408
+ assertValidTags: () => assertValidTags,
1406
1409
  checklistPath: () => checklistPath,
1407
1410
  computeCounts: () => computeCounts,
1408
1411
  decodeMetaValue: () => decodeMetaValue,
1409
1412
  encodeMetaValue: () => encodeMetaValue,
1410
1413
  generateShortId: () => generateShortId,
1411
1414
  generateUniqueId: () => generateUniqueId,
1415
+ isValidTag: () => isValidTag,
1412
1416
  logPath: () => logPath,
1413
1417
  parseChecklist: () => parseChecklist,
1414
1418
  parseChecklistItem: () => parseChecklistItem,
@@ -1438,6 +1442,18 @@ function generateUniqueId(existingIds) {
1438
1442
  }
1439
1443
  return id;
1440
1444
  }
1445
+ function isValidTag(tag) {
1446
+ return typeof tag === "string" && VALID_TAG_REGEX.test(tag);
1447
+ }
1448
+ function assertValidTags(tags) {
1449
+ for (const t of tags) {
1450
+ if (!isValidTag(t)) {
1451
+ throw new Error(
1452
+ `Invalid tag ${JSON.stringify(t)}: tags may contain only letters, digits, '-' and '_' (no spaces, newlines, or '#').`
1453
+ );
1454
+ }
1455
+ }
1456
+ }
1441
1457
  function encodeMetaValue(value) {
1442
1458
  let out = "";
1443
1459
  for (const ch of value) {
@@ -1629,6 +1645,7 @@ function parseChecklistItem(line) {
1629
1645
  }
1630
1646
  function serializeChecklistItem(item) {
1631
1647
  const marker = statusToMarker(item);
1648
+ assertValidTags(item.tags);
1632
1649
  const tagStr = item.tags.map((t) => `#${t}`).join(" ");
1633
1650
  const parts = [`- [${marker}] ${escapeDescription(item.description)}`];
1634
1651
  if (tagStr) parts.push(tagStr);
@@ -1818,7 +1835,7 @@ function computeCounts(items) {
1818
1835
  }
1819
1836
  return counts;
1820
1837
  }
1821
- var ITEM_REGEX, ID_REGEX, TAG_REGEX, META_TOKEN_REGEX, META_ENCODE_CHARS;
1838
+ var ITEM_REGEX, ID_REGEX, TAG_REGEX, META_TOKEN_REGEX, META_ENCODE_CHARS, VALID_TAG_REGEX;
1822
1839
  var init_parser2 = __esm({
1823
1840
  "src/todos/parser.ts"() {
1824
1841
  "use strict";
@@ -1829,6 +1846,7 @@ var init_parser2 = __esm({
1829
1846
  TAG_REGEX = /#([a-zA-Z0-9_-]+)/g;
1830
1847
  META_TOKEN_REGEX = /\[t:[a-f0-9]{4}\]\s+<([^>]*)>\s*$/;
1831
1848
  META_ENCODE_CHARS = ["%", "<", ">", "[", "]", "=", ";", "\n", "\r"];
1849
+ VALID_TAG_REGEX = /^[a-zA-Z0-9_-]+$/;
1832
1850
  }
1833
1851
  });
1834
1852
 
@@ -3951,6 +3969,16 @@ function parseStatusConfig(content) {
3951
3969
  }
3952
3970
  return t;
3953
3971
  };
3972
+ const unquoteAql = (v) => {
3973
+ const t = v.trim();
3974
+ if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) {
3975
+ return t.slice(1, -1).replace(/\\(["\\])/g, "$1");
3976
+ }
3977
+ if (t.startsWith("'") && t.endsWith("'") && t.length >= 2) {
3978
+ return t.slice(1, -1);
3979
+ }
3980
+ return t;
3981
+ };
3954
3982
  let currentSection = null;
3955
3983
  const lines = remaining.split("\n");
3956
3984
  function parseListEntry(lineIdx, baseIndent) {
@@ -4030,8 +4058,8 @@ function parseStatusConfig(content) {
4030
4058
  if (entry["phase"] && entry["when"] !== void 0) {
4031
4059
  phaseLadder.push({
4032
4060
  phase: unquote(entry["phase"]),
4033
- when: unquote(entry["when"]),
4034
- next: entry["next"] !== void 0 ? unquote(entry["next"]) : void 0
4061
+ when: unquoteAql(entry["when"]),
4062
+ next: entry["next"] !== void 0 ? unquoteAql(entry["next"]) : void 0
4035
4063
  });
4036
4064
  }
4037
4065
  lineIdx += consumed - 1;
@@ -4042,7 +4070,7 @@ function parseStatusConfig(content) {
4042
4070
  if (entry["else"] !== void 0) {
4043
4071
  disposition.push({ when: null, is: unquote(entry["else"]) });
4044
4072
  } else if (entry["when"] !== void 0 && entry["is"]) {
4045
- disposition.push({ when: unquote(entry["when"]), is: unquote(entry["is"]) });
4073
+ disposition.push({ when: unquoteAql(entry["when"]), is: unquote(entry["is"]) });
4046
4074
  }
4047
4075
  lineIdx += consumed - 1;
4048
4076
  continue;
@@ -4106,6 +4134,7 @@ function buildDefaultStatusConfig() {
4106
4134
  }
4107
4135
  function serializeStatusConfig(statuses) {
4108
4136
  const lines = [];
4137
+ const escapeAql = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
4109
4138
  lines.push("statuses:");
4110
4139
  lines.push(" definitions:");
4111
4140
  for (const s of statuses.statuses) {
@@ -4146,15 +4175,15 @@ function serializeStatusConfig(statuses) {
4146
4175
  lines.push(" phaseLadder:");
4147
4176
  for (const rung of d.phaseLadder) {
4148
4177
  lines.push(` - phase: ${rung.phase}`);
4149
- lines.push(` when: "${rung.when.replace(/"/g, '\\"')}"`);
4150
- if (rung.next) lines.push(` next: "${rung.next.replace(/"/g, '\\"')}"`);
4178
+ lines.push(` when: "${escapeAql(rung.when)}"`);
4179
+ if (rung.next !== void 0) lines.push(` next: "${escapeAql(rung.next)}"`);
4151
4180
  }
4152
4181
  lines.push(" disposition:");
4153
4182
  for (const rule of d.disposition) {
4154
4183
  if (rule.when === null) {
4155
4184
  lines.push(` - else: ${rule.is}`);
4156
4185
  } else {
4157
- lines.push(` - when: "${rule.when.replace(/"/g, '\\"')}"`);
4186
+ lines.push(` - when: "${escapeAql(rule.when)}"`);
4158
4187
  lines.push(` is: ${rule.is}`);
4159
4188
  }
4160
4189
  }
@@ -15079,6 +15108,14 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
15079
15108
  res.status(400).json({ error: "body is required" });
15080
15109
  return;
15081
15110
  }
15111
+ if (typeof author === "string" && /[\r\n]/.test(author)) {
15112
+ res.status(400).json({ error: "author must not contain newlines" });
15113
+ return;
15114
+ }
15115
+ if (typeof replyTo === "string" && /[\r\n]/.test(replyTo)) {
15116
+ res.status(400).json({ error: "replyTo must not contain newlines" });
15117
+ return;
15118
+ }
15082
15119
  const commentType = type && ["question", "note", "feedback"].includes(type) ? type : "note";
15083
15120
  const timestamp = nowTimestamp();
15084
15121
  const entryAuthor = typeof author === "string" && author.trim() ? author.trim() : "human";
@@ -16688,6 +16725,14 @@ async function appendCommentTo(assignmentDir, assignmentRef, req2, res, reloadDe
16688
16725
  }
16689
16726
  const commentType = type && ["question", "note", "feedback"].includes(type) ? type : "note";
16690
16727
  const timestamp = nowTimestamp();
16728
+ if (typeof author === "string" && /[\r\n]/.test(author)) {
16729
+ res.status(400).json({ error: "author must not contain newlines" });
16730
+ return;
16731
+ }
16732
+ if (typeof replyTo === "string" && /[\r\n]/.test(replyTo)) {
16733
+ res.status(400).json({ error: "replyTo must not contain newlines" });
16734
+ return;
16735
+ }
16691
16736
  const entryAuthor = typeof author === "string" && author.trim() ? author.trim() : "human";
16692
16737
  let currentContent;
16693
16738
  let currentCount = 0;
@@ -19676,19 +19721,18 @@ function evaluateKind(trigger, job, ctx) {
19676
19721
  }
19677
19722
  }
19678
19723
  function evaluateCron(trigger, now, createdAtMs) {
19679
- let cron;
19680
19724
  try {
19681
- cron = new Cron(trigger.expr, trigger.tz ? { timezone: trigger.tz } : {});
19725
+ const cron = new Cron(trigger.expr, trigger.tz ? { timezone: trigger.tz } : {});
19726
+ const prevs = cron.previousRuns(1, new Date(now.getTime() + 1e3));
19727
+ let prev = prevs.length > 0 ? prevs[0] : null;
19728
+ if (prev && prev.getTime() > now.getTime()) prev = null;
19729
+ if (prev && !Number.isNaN(createdAtMs) && prev.getTime() < createdAtMs) prev = null;
19730
+ const next = cron.nextRun(now);
19731
+ if (!prev) return { due: false, nextFireIso: next ? iso(next) : null };
19732
+ return { due: true, dedupeKey: `cron:${iso(prev)}`, nextFireIso: next ? iso(next) : null };
19682
19733
  } catch {
19683
19734
  return notDue;
19684
19735
  }
19685
- const prevs = cron.previousRuns(1, new Date(now.getTime() + 1e3));
19686
- let prev = prevs.length > 0 ? prevs[0] : null;
19687
- if (prev && prev.getTime() > now.getTime()) prev = null;
19688
- if (prev && !Number.isNaN(createdAtMs) && prev.getTime() < createdAtMs) prev = null;
19689
- const next = cron.nextRun(now);
19690
- if (!prev) return { due: false, nextFireIso: next ? iso(next) : null };
19691
- return { due: true, dedupeKey: `cron:${iso(prev)}`, nextFireIso: next ? iso(next) : null };
19692
19736
  }
19693
19737
  function evaluateAfterReset(trigger, now) {
19694
19738
  const v = verifyReset(trigger.provider, trigger.anchor, now);
@@ -22319,6 +22363,10 @@ function createTodosRouter(todosDir2, broadcast, projectsDir) {
22319
22363
  res.status(400).json({ error: "description is required" });
22320
22364
  return;
22321
22365
  }
22366
+ if (tags !== void 0 && (!Array.isArray(tags) || !tags.every(isValidTag))) {
22367
+ res.status(400).json({ error: "tags must be an array of [a-zA-Z0-9_-] strings (no spaces, newlines, or '#')" });
22368
+ return;
22369
+ }
22322
22370
  const item = await wsLock(workspace, async () => {
22323
22371
  const checklist = await readChecklist(todosDir2, workspace);
22324
22372
  const existingIds = new Set(checklist.items.map((i) => i.id));
@@ -22425,6 +22473,7 @@ workspace: ${workspace}
22425
22473
  }
22426
22474
  const completedItems = checklist.items.filter((i) => completedIds.has(i.id));
22427
22475
  for (const item of completedItems) {
22476
+ assertValidTags(item.tags);
22428
22477
  archContent += `- [x] ${item.description} ${item.tags.map((t) => `#${t}`).join(" ")} [t:${item.id}]
22429
22478
  `;
22430
22479
  }
@@ -22490,6 +22539,14 @@ workspace: ${workspace}
22490
22539
  router.patch("/:workspace/:id", async (req2, res) => {
22491
22540
  try {
22492
22541
  const workspace = getWorkspaceParam(req2.params.workspace);
22542
+ if (req2.body.tags !== void 0 && (!Array.isArray(req2.body.tags) || !req2.body.tags.every(isValidTag))) {
22543
+ res.status(400).json({ error: "tags must be an array of [a-zA-Z0-9_-] strings (no spaces, newlines, or '#')" });
22544
+ return;
22545
+ }
22546
+ if (req2.body.description !== void 0 && typeof req2.body.description !== "string") {
22547
+ res.status(400).json({ error: "description must be a string" });
22548
+ return;
22549
+ }
22493
22550
  const result = await wsLock(workspace, async () => {
22494
22551
  const checklist = await readChecklist(todosDir2, workspace);
22495
22552
  const item = checklist.items.find((i) => i.id === req2.params.id);
@@ -23027,6 +23084,10 @@ function createProjectTodosRouter(projectsDir, broadcast, workspaceTodosDir) {
23027
23084
  res.status(400).json({ error: "description is required" });
23028
23085
  return;
23029
23086
  }
23087
+ if (tags !== void 0 && (!Array.isArray(tags) || !tags.every(isValidTag))) {
23088
+ res.status(400).json({ error: "tags must be an array of [a-zA-Z0-9_-] strings (no spaces, newlines, or '#')" });
23089
+ return;
23090
+ }
23030
23091
  if (!await projectExists(projectsDir, slug)) {
23031
23092
  notFound(res, slug);
23032
23093
  return;
@@ -23171,6 +23232,7 @@ workspace: ${slug}
23171
23232
  }
23172
23233
  const completedItems = checklist.items.filter((i) => completedIds.has(i.id));
23173
23234
  for (const item of completedItems) {
23235
+ assertValidTags(item.tags);
23174
23236
  archContent += `- [x] ${item.description} ${item.tags.map((t) => `#${t}`).join(" ")} [t:${item.id}]
23175
23237
  `;
23176
23238
  }
@@ -23256,6 +23318,14 @@ workspace: ${slug}
23256
23318
  router.patch("/:id", async (req2, res) => {
23257
23319
  try {
23258
23320
  const slug = getProjectIdParam(params(req2).projectId);
23321
+ if (req2.body.tags !== void 0 && (!Array.isArray(req2.body.tags) || !req2.body.tags.every(isValidTag))) {
23322
+ res.status(400).json({ error: "tags must be an array of [a-zA-Z0-9_-] strings (no spaces, newlines, or '#')" });
23323
+ return;
23324
+ }
23325
+ if (req2.body.description !== void 0 && typeof req2.body.description !== "string") {
23326
+ res.status(400).json({ error: "description must be a string" });
23327
+ return;
23328
+ }
23259
23329
  if (!await projectExists(projectsDir, slug)) {
23260
23330
  notFound(res, slug);
23261
23331
  return;