@llblab/pi-kit 0.5.2 → 0.6.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 (37) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
  6. package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
  7. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  8. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  9. package/node_modules/@llblab/pi-telegram/BACKLOG.md +0 -1
  10. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +9 -5
  11. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  12. package/node_modules/@llblab/pi-telegram/docs/outbound.md +8 -2
  13. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -0
  14. package/node_modules/@llblab/pi-telegram/index.ts +22 -19
  15. package/node_modules/@llblab/pi-telegram/lib/activity.ts +19 -5
  16. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +9 -1
  17. package/node_modules/@llblab/pi-telegram/lib/config.ts +1 -1
  18. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
  19. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -1
  20. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +2 -2
  21. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +23 -30
  22. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +28 -42
  23. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +18 -14
  24. package/node_modules/@llblab/pi-telegram/lib/preview.ts +115 -70
  25. package/node_modules/@llblab/pi-telegram/lib/queue.ts +18 -8
  26. package/node_modules/@llblab/pi-telegram/lib/replies.ts +46 -38
  27. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +36 -3
  28. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  29. package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
  30. package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
  31. package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
  32. package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
  33. package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
  34. package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
  35. package/node_modules/@llblab/skills/package.json +1 -1
  36. package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
  37. package/package.json +4 -4
@@ -1498,6 +1498,10 @@ export interface TelegramAgentEndRuntimeDeps<
1498
1498
  updateStatus: () => void;
1499
1499
  dispatchNextQueuedTelegramTurn: () => void;
1500
1500
  scheduleActiveTurnDelivery?: (task: () => Promise<void>) => void;
1501
+ preparePreviewDelivery?: (isDeliveryActive: () => boolean) => Pick<
1502
+ TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>,
1503
+ "clearPreview" | "setPreviewPendingText" | "finalizeMarkdownPreview"
1504
+ > | undefined;
1501
1505
  preparePreviewClear?: (
1502
1506
  chatId: number,
1503
1507
  options?: { target?: TelegramQueueTarget; isDeliveryActive?: () => boolean },
@@ -1595,6 +1599,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
1595
1599
  schedule: (task: () => Promise<void>) => void;
1596
1600
  cancel: () => void;
1597
1601
  };
1602
+ preparePreviewDelivery?: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>["preparePreviewDelivery"];
1598
1603
  preparePreviewClear?: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>["preparePreviewClear"];
1599
1604
  clearPreview: TelegramAgentEndRuntimeDeps<
1600
1605
  TTurn,
@@ -1746,6 +1751,7 @@ export function createTelegramAgentEndHook<
1746
1751
  await task();
1747
1752
  })
1748
1753
  : undefined,
1754
+ preparePreviewDelivery: deps.preparePreviewDelivery,
1749
1755
  preparePreviewClear: deps.preparePreviewClear,
1750
1756
  clearPreview: deps.clearPreview,
1751
1757
  setPreviewPendingText: deps.setPreviewPendingText,
@@ -1807,9 +1813,13 @@ export async function handleTelegramAgentEndRuntime<
1807
1813
  const isDeliveryActive = (): boolean =>
1808
1814
  deps.isSessionActive?.() !== false &&
1809
1815
  (!turn || deps.isTurnTransportActive?.(turn) !== false);
1816
+ const preview = turn && !turn.guestQueryId ? deps.preparePreviewDelivery?.(isDeliveryActive) : undefined;
1817
+ const setPreviewPendingText = preview?.setPreviewPendingText ?? deps.setPreviewPendingText;
1818
+ const finalizeMarkdownPreview = preview?.finalizeMarkdownPreview ?? deps.finalizeMarkdownPreview;
1810
1819
  const clearPreview = turn
1811
- ? deps.preparePreviewClear?.(turn.chatId, { target: turn.target, isDeliveryActive })
1812
- ?? (() => deps.clearPreview(turn.chatId, { target: turn.target }))
1820
+ ? preview ? () => preview.clearPreview(turn.chatId, { target: turn.target })
1821
+ : deps.preparePreviewClear?.(turn.chatId, { target: turn.target, isDeliveryActive })
1822
+ ?? (() => deps.clearPreview(turn.chatId, { target: turn.target }))
1813
1823
  : undefined;
1814
1824
  const updateStatusIgnoringStaleContext = (): void => {
1815
1825
  try {
@@ -1914,7 +1924,7 @@ export async function handleTelegramAgentEndRuntime<
1914
1924
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1915
1925
  return;
1916
1926
  }
1917
- if (finalText) deps.setPreviewPendingText(finalText);
1927
+ if (finalText) setPreviewPendingText(finalText);
1918
1928
 
1919
1929
  if (!isDeliveryActive()) return;
1920
1930
  let richAttachmentDelivered = false;
@@ -1932,9 +1942,9 @@ export async function handleTelegramAgentEndRuntime<
1932
1942
  );
1933
1943
  if (!isDeliveryActive()) return;
1934
1944
  if (richAttachmentDelivered) {
1935
- await deps.clearPreview(turn.chatId, { target: turn.target });
1945
+ await clearPreview?.();
1936
1946
  if (!isDeliveryActive()) return;
1937
- deps.setPreviewPendingText("");
1947
+ setPreviewPendingText("");
1938
1948
  }
1939
1949
  } catch (error) {
1940
1950
  if (!isDeliveryActive()) return;
@@ -1949,7 +1959,7 @@ export async function handleTelegramAgentEndRuntime<
1949
1959
  if (!isDeliveryActive()) return;
1950
1960
  if (!richAttachmentDelivered && endPlan.kind === "text" && finalText) {
1951
1961
  try {
1952
- const finalized = await deps.finalizeMarkdownPreview(
1962
+ const finalized = await finalizeMarkdownPreview(
1953
1963
  turn.chatId,
1954
1964
  finalText,
1955
1965
  turn.replyToMessageId,
@@ -1957,7 +1967,7 @@ export async function handleTelegramAgentEndRuntime<
1957
1967
  );
1958
1968
  if (!isDeliveryActive()) return;
1959
1969
  if (!finalized) {
1960
- await deps.clearPreview(turn.chatId, { target: turn.target });
1970
+ await clearPreview?.();
1961
1971
  if (!isDeliveryActive()) return;
1962
1972
  await deps.sendMarkdownReply(
1963
1973
  turn.chatId,
@@ -1967,7 +1977,7 @@ export async function handleTelegramAgentEndRuntime<
1967
1977
  );
1968
1978
  }
1969
1979
  if (!isDeliveryActive()) return;
1970
- deps.setPreviewPendingText("");
1980
+ setPreviewPendingText("");
1971
1981
  } catch (error) {
1972
1982
  deps.recordRuntimeEvent?.("delivery", error, {
1973
1983
  phase: "final-text",
@@ -9,6 +9,7 @@ import {
9
9
  getTelegramTargetThreadParams,
10
10
  type TelegramTarget,
11
11
  } from "./target.ts";
12
+ import { isTelegramApiCommitUnknownError } from "./telegram-api.ts";
12
13
  import type {
13
14
  TelegramInputRichMessage,
14
15
  TelegramReplyParameters,
@@ -67,6 +68,7 @@ export function createReplyDedupRuntime(): ReplyDedupRuntime {
67
68
  // --- Transport-level dedup ---
68
69
 
69
70
  const lastRepliedToMessageIdByTarget = new Map<string, number>();
71
+ let replyDedupGeneration = 0;
70
72
 
71
73
  function getReplyDedupTargetKey(
72
74
  chatId: number,
@@ -79,6 +81,7 @@ function getReplyDedupTargetKey(
79
81
  }
80
82
 
81
83
  export function resetTransportReplyDedup(): void {
84
+ replyDedupGeneration += 1;
82
85
  lastRepliedToMessageIdByTarget.clear();
83
86
  }
84
87
 
@@ -99,13 +102,29 @@ export function buildTelegramReplyParameters(
99
102
  };
100
103
  }
101
104
 
102
- export function buildTelegramMultipartReplyParameters(
105
+ // Answer publications are caller-serialized. A rejected send releases its anchor;
106
+ // an uncertain ACK retains it because Telegram may already have delivered it.
107
+ export async function withTelegramReplyParameters<T>(
103
108
  chatId: number,
104
109
  messageId: number | undefined,
105
- target?: TelegramTarget,
106
- ): string | undefined {
110
+ target: TelegramTarget | undefined,
111
+ send: (parameters: TelegramReplyParameters | undefined) => Promise<T>,
112
+ ): Promise<T> {
113
+ const key = getReplyDedupTargetKey(chatId, target);
114
+ const generation = replyDedupGeneration;
115
+ const previous = lastRepliedToMessageIdByTarget.get(key);
107
116
  const parameters = buildTelegramReplyParameters(chatId, messageId, target);
108
- return parameters ? JSON.stringify(parameters) : undefined;
117
+ try {
118
+ return await send(parameters);
119
+ } catch (error) {
120
+ if (parameters && !isTelegramApiCommitUnknownError(error)
121
+ && generation === replyDedupGeneration
122
+ && lastRepliedToMessageIdByTarget.get(key) === messageId) {
123
+ if (previous === undefined) lastRepliedToMessageIdByTarget.delete(key);
124
+ else lastRepliedToMessageIdByTarget.set(key, previous);
125
+ }
126
+ throw error;
127
+ }
109
128
  }
110
129
 
111
130
  function getAgentMessageField(message: unknown, field: string): unknown {
@@ -239,24 +258,18 @@ export async function sendTelegramRenderedChunks<TReplyMarkup>(
239
258
  assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
240
259
  let lastMessageId: number | undefined;
241
260
  for (const [index, chunk] of chunks.entries()) {
242
- const replyParameters =
243
- index === 0
244
- ? buildTelegramReplyParameters(
245
- chatId,
246
- options?.replyToMessageId,
247
- options?.target,
248
- )
249
- : undefined;
250
- const body = {
251
- chat_id: chatId,
252
- text: chunk.text,
253
- parse_mode: chunk.parseMode,
254
- reply_markup:
255
- index === chunks.length - 1 ? options?.replyMarkup : undefined,
256
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
257
- ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
258
- };
259
- const sent = await deps.sendMessage(body);
261
+ const sent = await withTelegramReplyParameters(
262
+ chatId, index === 0 ? options?.replyToMessageId : undefined, options?.target,
263
+ (replyParameters) => deps.sendMessage({
264
+ chat_id: chatId,
265
+ text: chunk.text,
266
+ parse_mode: chunk.parseMode,
267
+ reply_markup:
268
+ index === chunks.length - 1 ? options?.replyMarkup : undefined,
269
+ ...(replyParameters ? { reply_parameters: replyParameters } : {}),
270
+ ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
271
+ }),
272
+ );
260
273
  lastMessageId = sent.message_id;
261
274
  deps.recordOwnership?.({
262
275
  chatId,
@@ -655,22 +668,17 @@ export async function sendTelegramNativeMarkdownReply<TReplyMarkup = unknown>(
655
668
  let lastMessageId: number | undefined;
656
669
  const chunks = splitTelegramNativeMarkdown(markdown);
657
670
  for (const [index, chunk] of chunks.entries()) {
658
- const replyParameters =
659
- index === 0
660
- ? buildTelegramReplyParameters(
661
- chatId,
662
- replyToMessageId,
663
- options?.target,
664
- )
665
- : undefined;
666
- const sent = await deps.sendRichMessage({
667
- chat_id: chatId,
668
- rich_message: { markdown: chunk, skip_entity_detection: true },
669
- reply_markup:
670
- index === chunks.length - 1 ? options?.replyMarkup : undefined,
671
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
672
- ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
673
- });
671
+ const sent = await withTelegramReplyParameters(
672
+ chatId, index === 0 ? replyToMessageId : undefined, options?.target,
673
+ (replyParameters) => deps.sendRichMessage({
674
+ chat_id: chatId,
675
+ rich_message: { markdown: chunk, skip_entity_detection: true },
676
+ reply_markup:
677
+ index === chunks.length - 1 ? options?.replyMarkup : undefined,
678
+ ...(replyParameters ? { reply_parameters: replyParameters } : {}),
679
+ ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
680
+ }),
681
+ );
674
682
  lastMessageId = sent.message_id;
675
683
  deps.recordOwnership?.({
676
684
  chatId,
@@ -1783,11 +1783,44 @@ export function createTelegramBridgeApiRuntime(
1783
1783
  */
1784
1784
  export function createTelegramApiClient(
1785
1785
  getBotToken: () => string | undefined,
1786
- options: TelegramAnswerCallbackQueryOptions = {},
1786
+ options: TelegramAnswerCallbackQueryOptions & { now?: () => number } = {},
1787
1787
  ): TelegramApiClient {
1788
+ const now = options.now ?? Date.now;
1789
+ const draftRetryNotBeforeByTarget = new Map<string, number>();
1788
1790
  return {
1789
- call: async (method, body, options) => {
1790
- return callTelegram(getBotToken(), method, body, options);
1791
+ call: async <TResponse>(
1792
+ method: string,
1793
+ body: Record<string, unknown>,
1794
+ options?: TelegramApiCallOptions,
1795
+ ): Promise<TResponse> => {
1796
+ const token = getBotToken();
1797
+ // Cooldown keys retain only the public bot-id prefix, not the credential.
1798
+ const botId = token?.match(/^(\d+):/)?.[1];
1799
+ const isDraft = method === "sendMessageDraft" || method === "sendRichMessageDraft";
1800
+ const draftKey = isDraft && botId
1801
+ ? `${botId}:${String(body.chat_id)}:${String(body.message_thread_id ?? "all")}`
1802
+ : undefined;
1803
+ if (draftKey) {
1804
+ const nowMs = now();
1805
+ for (const [key, deadline] of draftRetryNotBeforeByTarget) {
1806
+ if (nowMs >= deadline) draftRetryNotBeforeByTarget.delete(key);
1807
+ }
1808
+ if (draftRetryNotBeforeByTarget.has(draftKey)) return false as TResponse;
1809
+ }
1810
+ try {
1811
+ // A draft is a replaceable snapshot, not a body to replay after backoff.
1812
+ return await callTelegram<TResponse>(
1813
+ token, method, body, isDraft ? { ...options, maxAttempts: 1 } : options,
1814
+ );
1815
+ } catch (error) {
1816
+ if (draftKey && isRetryableTelegramApiError(error)) {
1817
+ draftRetryNotBeforeByTarget.set(draftKey, Math.max(
1818
+ draftRetryNotBeforeByTarget.get(draftKey) ?? 0,
1819
+ now() + getTelegramRetryDelayMs(error, 0, options?.retryBaseDelayMs ?? 500),
1820
+ ));
1821
+ }
1822
+ throw error;
1823
+ }
1791
1824
  },
1792
1825
  callMultipart: async (
1793
1826
  method,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.43.2",
3
+ "version": "0.44.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -36,5 +36,6 @@
36
36
  - `Path Portability`: Resolve paths through Node rather than platform-specific shell utilities.
37
37
  - `Human Output`: Emit concise readable logs and a summary; `NO_COLOR=1` suppresses color.
38
38
  - `Bounded Scanning`: Skip link scanning above the configured byte threshold while validating the surrounding graph.
39
+ - `Contiguous Lists`: Fail blank lines inside one Markdown list; list items are contiguous structural peers, not paragraphs. Ignore fenced examples and bounded-scan exclusions.
39
40
  - `Compact Tables`: Fail non-compact delimiter cells; require exactly three hyphens with one space inside each pipe, while table rows over 120 characters only warn.
40
41
  - `Core Shape Flexibility`: Accept both numbered mature-project sections and compact skill-style durable sections.
@@ -1,8 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## 1.5.3
3
+ ## 1.14.1
4
4
 
5
- - `Markdown Freedom`: LaTeX syntax is now allowed, while table checks always enforce compact three-hyphen delimiter cells and warn on rows over 120 characters without imposing a maximum. Indented backtick and tilde code fences remain excluded from table validation. Impact: context authors can use mathematical notation and fenced examples while receiving default, noise-focused table formatting guidance.
5
+ - `Contiguous Markdown Lists`: The validator now rejects blank lines inside continuous lists, including multiline and nested item boundaries, while ignoring fenced examples and bounded large-reference scans. Impact: list items remain structural peers instead of being formatted as separate paragraphs.
6
+
7
+ ## 1.13.2
8
+
9
+ - `Markdown Freedom`: LaTeX syntax is now allowed, while table checks always enforce compact three-hyphen delimiter cells and warn on rows over 120 characters without imposing a maximum. Indented backtick and tilde code fences remain excluded from table validation. Impact: context authors can use mathematical notation and fenced examples while receiving default, noise-focused table formatting guidance
6
10
 
7
11
  ## 1.5.2
8
12
 
@@ -102,7 +102,7 @@ Run the Node validator from the project root or pass an explicit root:
102
102
  node "${SKILL_DIR}/scripts/validate-context.mjs" /path/to/project
103
103
  ```
104
104
 
105
- Output is human-readable. Markdown table checks always require compact delimiter cells such as `| --- | ---: | :--- |`; non-compact delimiters fail validation, while rows longer than 120 characters only warn without imposing a maximum length.
105
+ Output is human-readable. Markdown list items must remain contiguous: blank lines inside one list fail validation, including between multiline or nested items, while fenced examples are ignored. Markdown table checks always require compact delimiter cells such as `| --- | ---: | :--- |`; non-compact delimiters fail validation, while rows longer than 120 characters only warn without imposing a maximum length.
106
106
 
107
107
  Useful environment controls:
108
108
 
@@ -28,10 +28,11 @@ A missing or non-directory root fails before validation.
28
28
  6. `README reachability — Warning`: Finds subtree README files with no inbound Markdown link.
29
29
  7. `Meta-protocol presence — Warning`: Checks the durable file for `Meta-Protocol Principles`.
30
30
  8. `Bloat signals — Warning`: Reports low information density or sparse structure in the durable file.
31
- 9. `Markdown tables — Error/Warning`: Always fails non-compact delimiter rows and warns about table rows longer than 120 characters.
32
- 10. `Freshness — Warning`: Reports durable files older than 30 days.
33
- 11. `Docs directory — Warning`: Checks for `/docs`.
34
- 12. `Docs index coverage — Warning`: Detects docs missing from `docs/README.md` and indexed files that do not exist.
31
+ 9. `Markdown lists — Error`: Fails blank lines within one continuous list, including multiline and nested items, while ignoring fenced examples and files above the bounded Markdown scan limit.
32
+ 10. `Markdown tables Error/Warning`: Always fails non-compact delimiter rows and warns about table rows longer than 120 characters.
33
+ 11. `Freshness — Warning`: Reports durable files older than 30 days.
34
+ 12. `Docs directory — Warning`: Checks for `/docs`.
35
+ 13. `Docs index coverage — Warning`: Detects docs missing from `docs/README.md` and indexed files that do not exist.
35
36
 
36
37
  ## Severity Contract
37
38
 
@@ -70,6 +71,10 @@ The validator avoids a hard file-length limit. It checks independent signals:
70
71
 
71
72
  Signals suggest consolidation; they do not replace judgment.
72
73
 
74
+ ## Markdown Lists
75
+
76
+ List checks apply to Markdown within the bounded scan size and outside fenced code blocks. A blank line fails when it separates sibling list items, a list item from its nested list, nested sibling items, or a multiline item's continuation from its next item. Paragraphs and independent blocks remain separated by blank lines normally.
77
+
73
78
  ## Markdown Tables
74
79
 
75
80
  Table checks always run and have no enabling option.
@@ -93,7 +98,8 @@ Validation prints each check and a summary by default. `NO_COLOR=1` disables ANS
93
98
  3. A missing path, which must fail clearly.
94
99
  4. The removed `--json` option, which must fail clearly.
95
100
  5. A temporary fixture with an out-of-range line reference, which must fail clearly.
96
- 6. Temporary fixtures proving that LaTeX and compact table delimiters pass, non-compact delimiters fail, and rows over 120 characters warn.
101
+ 6. Temporary fixtures proving that contiguous multiline/nested lists and fenced examples pass while blank separators inside lists fail.
102
+ 7. Temporary fixtures proving that LaTeX and compact table delimiters pass, non-compact delimiters fail, and rows over 120 characters warn.
97
103
 
98
104
  The fixture remains linked from [its README](../fixtures/abcd-project/README.md).
99
105
 
@@ -23,6 +23,7 @@ const missingPathOutput = run([path.join(fixtureRoot, "missing")], {
23
23
  const jsonOptionOutput = run(["--json"]);
24
24
  const invalidLineRefRoot = createInvalidLineRefFixture();
25
25
  const tableRoot = createTableFixture();
26
+ const listRoot = createListFixture();
26
27
  try {
27
28
  const invalidLineRefOutput = run([invalidLineRefRoot], {
28
29
  withoutRootEnv: true,
@@ -49,6 +50,12 @@ try {
49
50
  .replace("|------------|---:|", "| --- | ---: |"),
50
51
  );
51
52
  const wideTableOutput = run([tableRoot], { withoutRootEnv: true });
53
+ const tightListOutput = run([listRoot], { withoutRootEnv: true });
54
+ fs.appendFileSync(
55
+ path.join(listRoot, "docs/lists.md"),
56
+ "\n- First loose item.\n Continued detail.\n\n- Second loose item.\n - Nested item.\n\n - Nested sibling.\n",
57
+ );
58
+ const looseListOutput = run([listRoot], { withoutRootEnv: true });
52
59
 
53
60
  checkSuccess("default fixture", defaultOutput);
54
61
  checkSuccess("fixture path arg", pathOutput);
@@ -59,9 +66,12 @@ try {
59
66
  checkCompactTableAndLatex(compactTableOutput);
60
67
  checkNonCompactTable(nonCompactTableOutput);
61
68
  checkWideTable(wideTableOutput);
69
+ checkTightLists(tightListOutput);
70
+ checkLooseLists(looseListOutput);
62
71
  } finally {
63
72
  fs.rmSync(invalidLineRefRoot, { recursive: true, force: true });
64
73
  fs.rmSync(tableRoot, { recursive: true, force: true });
74
+ fs.rmSync(listRoot, { recursive: true, force: true });
65
75
  }
66
76
 
67
77
  console.log("PASS: validate-context fixture + self-reference regression");
@@ -146,6 +156,57 @@ function checkWideTable(result) {
146
156
  );
147
157
  }
148
158
 
159
+ function checkTightLists(result) {
160
+ assert(result.status === 0, "contiguous lists and fenced examples pass");
161
+ assert(
162
+ result.stdout.includes("Markdown list checks passed"),
163
+ "contiguous list check reports success",
164
+ );
165
+ }
166
+
167
+ function checkLooseLists(result) {
168
+ assert(result.status !== 0, "blank lines inside lists fail validation");
169
+ assert(
170
+ result.stdout.includes("Blank line inside Markdown list: docs/lists.md"),
171
+ "loose list failure identifies its file",
172
+ );
173
+ }
174
+
175
+ function createListFixture() {
176
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "abcd-context-list-"));
177
+ fs.mkdirSync(path.join(root, "docs"));
178
+ fs.writeFileSync(
179
+ path.join(root, "README.md"),
180
+ "# Probe\n\n[Context](./AGENTS.md) [Backlog](./BACKLOG.md) [History](./CHANGELOG.md) [Docs](./docs/README.md)\n",
181
+ );
182
+ fs.writeFileSync(
183
+ path.join(root, "AGENTS.md"),
184
+ "# Context\n\n## Meta-Protocol Principles\n\n- First rule.\n- Second rule.\n\n## Operating Principles\n\n- Rule.\n",
185
+ );
186
+ fs.writeFileSync(path.join(root, "BACKLOG.md"), "# Backlog\n\nNo open work.\n");
187
+ fs.writeFileSync(path.join(root, "CHANGELOG.md"), "# Changelog\n\nNo releases.\n");
188
+ fs.writeFileSync(path.join(root, "docs/README.md"), "# Docs\n\n- [Lists](./lists.md)\n");
189
+ fs.writeFileSync(
190
+ path.join(root, "docs/lists.md"),
191
+ [
192
+ "# Lists",
193
+ "",
194
+ "1. First item.",
195
+ "2. Second item.",
196
+ " - Nested item.",
197
+ " - Nested sibling.",
198
+ "",
199
+ "```markdown",
200
+ "- Fenced item.",
201
+ "",
202
+ "- Fenced loose item.",
203
+ "```",
204
+ "",
205
+ ].join("\n"),
206
+ );
207
+ return root;
208
+ }
209
+
149
210
  function createTableFixture() {
150
211
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "abcd-context-table-"));
151
212
  fs.mkdirSync(path.join(root, "docs"));
@@ -16,6 +16,7 @@ for (let index = 0; index < args.length; index += 1) {
16
16
  "Validates the current directory by default, VALIDATE_CONTEXT_ROOT when set,",
17
17
  "or the explicit project-root argument when provided.",
18
18
  "",
19
+ "Markdown list items must be contiguous; blank separators fail validation.",
19
20
  "Markdown table formatting is always checked. Rows over 120 characters warn.",
20
21
  ].join("\n"),
21
22
  );
@@ -167,6 +168,53 @@ function markdownLines(text) {
167
168
  });
168
169
  }
169
170
 
171
+ function markdownIndent(line) {
172
+ const leading = line.match(/^[ \t]*/)?.[0] || "";
173
+ return [...leading].reduce((width, char) => width + (char === "\t" ? 4 : 1), 0);
174
+ }
175
+
176
+ function findLooseListBlankLines(text) {
177
+ const lines = markdownLines(text);
178
+ const listIndents = [];
179
+ let pendingBlankLine;
180
+ const violations = [];
181
+ for (let index = 0; index < lines.length; index += 1) {
182
+ const { line, outsideFence } = lines[index];
183
+ if (!outsideFence) {
184
+ listIndents.length = 0;
185
+ pendingBlankLine = undefined;
186
+ continue;
187
+ }
188
+ if (/^\s*$/.test(line)) {
189
+ if (listIndents.length > 0 && pendingBlankLine === undefined) {
190
+ pendingBlankLine = index + 1;
191
+ }
192
+ continue;
193
+ }
194
+ const item = line.match(/^([ \t]*)(?:[-+*]|\d+[.)])[ \t]+\S/);
195
+ if (item) {
196
+ const indent = markdownIndent(item[1]);
197
+ if (pendingBlankLine !== undefined) violations.push(pendingBlankLine);
198
+ while (listIndents.length > 0 && listIndents.at(-1) > indent) listIndents.pop();
199
+ if (listIndents.at(-1) !== indent) listIndents.push(indent);
200
+ pendingBlankLine = undefined;
201
+ continue;
202
+ }
203
+ if (pendingBlankLine !== undefined) {
204
+ if (markdownIndent(line) > (listIndents.at(-1) ?? 0)) {
205
+ violations.push(pendingBlankLine);
206
+ pendingBlankLine = undefined;
207
+ continue;
208
+ }
209
+ listIndents.length = 0;
210
+ pendingBlankLine = undefined;
211
+ continue;
212
+ }
213
+ if (/^ {0,3}(?:#{1,6}\s|>|\|)/.test(line)) listIndents.length = 0;
214
+ }
215
+ return violations;
216
+ }
217
+
170
218
  function stripFences(text) {
171
219
  return markdownLines(text)
172
220
  .filter(({ outsideFence }) => outsideFence)
@@ -392,6 +440,25 @@ if (contextFile) {
392
440
 
393
441
  const docsDir = path.join(root, "docs");
394
442
 
443
+ progress("Checking Markdown lists...");
444
+ let listIssue = false;
445
+ for (const file of mdFiles) {
446
+ const sourceSize = fs.statSync(file).size;
447
+ if (sourceSize > markdownLinkScanMaxBytes) {
448
+ info(
449
+ `Skipped list validation for large Markdown file: ${rel(file)} (${sourceSize} bytes > ${markdownLinkScanMaxBytes})`,
450
+ );
451
+ continue;
452
+ }
453
+ for (const line of findLooseListBlankLines(read(file))) {
454
+ fail(
455
+ `Blank line inside Markdown list: ${rel(file)}:${line} (keep adjacent list items contiguous)`,
456
+ );
457
+ listIssue = true;
458
+ }
459
+ }
460
+ if (!listIssue) pass("Markdown list checks passed");
461
+
395
462
  progress("Checking Markdown tables...");
396
463
  let tableIssue = false;
397
464
  for (const file of mdFiles) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/skills",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "private": false,
5
5
  "description": "Dream Skills",
6
6
  "keywords": [
@@ -210,7 +210,6 @@ After the hard gate passes:
210
210
  1. Reconfirm immediately before push that local `dev` and `origin/dev` remain absent. Stop if either now exists or the remote check fails.
211
211
  2. Push the release commit from local `main` to `origin/main` without creating a pull request.
212
212
  3. Verify that `origin/main` resolves to the pushed local `main` commit.
213
-
214
213
  4. Confirm the version on `main` matches the intended release version and that the current `main` commit is the released commit on `origin/main`.
215
214
  5. Create and push exactly one release tag for the confirmed version on the current `main` commit:
216
215
 
@@ -268,9 +267,8 @@ npm view <package-name> version
268
267
  Treat an explicit npm not-found response as package absence. Authentication, authorization, network, registry-resolution, and other lookup failures are not absence; stop and report them. Automation-owned npm publication already performed this lookup before the hard gate and must not defer it until after tagging.
269
268
 
270
269
  15. Publish only when both conditions are true:
271
-
272
- - The package already exists on npm.
273
- - The `main` version matches the intended release version and is newer than the npm version established before publication.
270
+ - The package already exists on npm.
271
+ - The `main` version matches the intended release version and is newer than the npm version established before publication.
274
272
 
275
273
  When repository automation owns npm publication, never run `npm publish` manually. After all owning tag workflows succeed, require `npm view <package-name>@<version> version` to resolve to the intended version; stop if the package remains absent or mismatched.
276
274
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -43,10 +43,10 @@
43
43
  "@llblab/pi-actors": "0.52.1",
44
44
  "@llblab/pi-clean-room": "0.1.1",
45
45
  "@llblab/pi-codex-usage": "0.9.4",
46
- "@llblab/pi-grow-loop": "0.7.4",
46
+ "@llblab/pi-grow-loop": "0.7.5",
47
47
  "@llblab/pi-state-flow": "0.3.0",
48
- "@llblab/pi-telegram": "0.43.2",
49
- "@llblab/skills": "1.14.0"
48
+ "@llblab/pi-telegram": "0.44.0",
49
+ "@llblab/skills": "1.14.1"
50
50
  },
51
51
  "bundledDependencies": [
52
52
  "@llblab/pi-actors",