gsd-pi 2.8.0 → 2.8.2

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 (143) hide show
  1. package/dist/loader.js +5 -0
  2. package/node_modules/@gsd/pi-coding-agent/dist/config.d.ts +2 -0
  3. package/node_modules/@gsd/pi-coding-agent/dist/config.d.ts.map +1 -1
  4. package/node_modules/@gsd/pi-coding-agent/dist/config.js +4 -0
  5. package/node_modules/@gsd/pi-coding-agent/dist/config.js.map +1 -1
  6. package/node_modules/@gsd/pi-coding-agent/dist/core/artifact-manager.d.ts +52 -0
  7. package/node_modules/@gsd/pi-coding-agent/dist/core/artifact-manager.d.ts.map +1 -0
  8. package/node_modules/@gsd/pi-coding-agent/dist/core/artifact-manager.js +117 -0
  9. package/node_modules/@gsd/pi-coding-agent/dist/core/artifact-manager.js.map +1 -0
  10. package/node_modules/@gsd/pi-coding-agent/dist/core/bash-executor.js +2 -2
  11. package/node_modules/@gsd/pi-coding-agent/dist/core/bash-executor.js.map +1 -1
  12. package/node_modules/@gsd/pi-coding-agent/dist/core/blob-store.d.ts +31 -0
  13. package/node_modules/@gsd/pi-coding-agent/dist/core/blob-store.d.ts.map +1 -0
  14. package/node_modules/@gsd/pi-coding-agent/dist/core/blob-store.js +97 -0
  15. package/node_modules/@gsd/pi-coding-agent/dist/core/blob-store.js.map +1 -0
  16. package/node_modules/@gsd/pi-coding-agent/dist/core/session-manager.d.ts +1 -0
  17. package/node_modules/@gsd/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  18. package/node_modules/@gsd/pi-coding-agent/dist/core/session-manager.js +112 -3
  19. package/node_modules/@gsd/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  20. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts +4 -0
  21. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  22. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js +32 -22
  23. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  24. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.d.ts +1 -1
  25. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.d.ts.map +1 -1
  26. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.js +13 -2
  27. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.js.map +1 -1
  28. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.test.d.ts +2 -0
  29. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.test.d.ts.map +1 -0
  30. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.test.js +57 -0
  31. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.test.js.map +1 -0
  32. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts +3 -1
  33. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts.map +1 -1
  34. package/node_modules/@gsd/pi-coding-agent/dist/index.js +4 -1
  35. package/node_modules/@gsd/pi-coding-agent/dist/index.js.map +1 -1
  36. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  37. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +7 -5
  38. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  39. package/node_modules/@gsd/pi-coding-agent/dist/utils/shell.d.ts +7 -0
  40. package/node_modules/@gsd/pi-coding-agent/dist/utils/shell.d.ts.map +1 -1
  41. package/node_modules/@gsd/pi-coding-agent/dist/utils/shell.js +11 -0
  42. package/node_modules/@gsd/pi-coding-agent/dist/utils/shell.js.map +1 -1
  43. package/node_modules/@gsd/pi-coding-agent/src/config.ts +5 -0
  44. package/node_modules/@gsd/pi-coding-agent/src/core/artifact-manager.ts +125 -0
  45. package/node_modules/@gsd/pi-coding-agent/src/core/bash-executor.ts +2 -2
  46. package/node_modules/@gsd/pi-coding-agent/src/core/blob-store.ts +106 -0
  47. package/node_modules/@gsd/pi-coding-agent/src/core/session-manager.ts +119 -3
  48. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash.ts +35 -22
  49. package/node_modules/@gsd/pi-coding-agent/src/core/tools/path-utils.test.ts +66 -0
  50. package/node_modules/@gsd/pi-coding-agent/src/core/tools/path-utils.ts +14 -2
  51. package/node_modules/@gsd/pi-coding-agent/src/index.ts +4 -1
  52. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +6 -4
  53. package/node_modules/@gsd/pi-coding-agent/src/utils/shell.ts +11 -0
  54. package/package.json +6 -1
  55. package/packages/pi-coding-agent/dist/config.d.ts +2 -0
  56. package/packages/pi-coding-agent/dist/config.d.ts.map +1 -1
  57. package/packages/pi-coding-agent/dist/config.js +4 -0
  58. package/packages/pi-coding-agent/dist/config.js.map +1 -1
  59. package/packages/pi-coding-agent/dist/core/artifact-manager.d.ts +52 -0
  60. package/packages/pi-coding-agent/dist/core/artifact-manager.d.ts.map +1 -0
  61. package/packages/pi-coding-agent/dist/core/artifact-manager.js +117 -0
  62. package/packages/pi-coding-agent/dist/core/artifact-manager.js.map +1 -0
  63. package/packages/pi-coding-agent/dist/core/bash-executor.js +2 -2
  64. package/packages/pi-coding-agent/dist/core/bash-executor.js.map +1 -1
  65. package/packages/pi-coding-agent/dist/core/blob-store.d.ts +31 -0
  66. package/packages/pi-coding-agent/dist/core/blob-store.d.ts.map +1 -0
  67. package/packages/pi-coding-agent/dist/core/blob-store.js +97 -0
  68. package/packages/pi-coding-agent/dist/core/blob-store.js.map +1 -0
  69. package/packages/pi-coding-agent/dist/core/session-manager.d.ts +1 -0
  70. package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  71. package/packages/pi-coding-agent/dist/core/session-manager.js +112 -3
  72. package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  73. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +4 -0
  74. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  75. package/packages/pi-coding-agent/dist/core/tools/bash.js +32 -22
  76. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/tools/path-utils.d.ts +1 -1
  78. package/packages/pi-coding-agent/dist/core/tools/path-utils.d.ts.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/tools/path-utils.js +13 -2
  80. package/packages/pi-coding-agent/dist/core/tools/path-utils.js.map +1 -1
  81. package/packages/pi-coding-agent/dist/core/tools/path-utils.test.d.ts +2 -0
  82. package/packages/pi-coding-agent/dist/core/tools/path-utils.test.d.ts.map +1 -0
  83. package/packages/pi-coding-agent/dist/core/tools/path-utils.test.js +57 -0
  84. package/packages/pi-coding-agent/dist/core/tools/path-utils.test.js.map +1 -0
  85. package/packages/pi-coding-agent/dist/index.d.ts +3 -1
  86. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/index.js +4 -1
  88. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  89. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  90. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +7 -5
  91. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  92. package/packages/pi-coding-agent/dist/utils/shell.d.ts +7 -0
  93. package/packages/pi-coding-agent/dist/utils/shell.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/utils/shell.js +11 -0
  95. package/packages/pi-coding-agent/dist/utils/shell.js.map +1 -1
  96. package/packages/pi-coding-agent/src/config.ts +5 -0
  97. package/packages/pi-coding-agent/src/core/artifact-manager.ts +125 -0
  98. package/packages/pi-coding-agent/src/core/bash-executor.ts +2 -2
  99. package/packages/pi-coding-agent/src/core/blob-store.ts +106 -0
  100. package/packages/pi-coding-agent/src/core/session-manager.ts +119 -3
  101. package/packages/pi-coding-agent/src/core/tools/bash.ts +35 -22
  102. package/packages/pi-coding-agent/src/core/tools/path-utils.test.ts +66 -0
  103. package/packages/pi-coding-agent/src/core/tools/path-utils.ts +14 -2
  104. package/packages/pi-coding-agent/src/index.ts +4 -1
  105. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +6 -4
  106. package/packages/pi-coding-agent/src/utils/shell.ts +11 -0
  107. package/src/resources/extensions/bg-shell/index.ts +2 -1
  108. package/src/resources/extensions/browser-tools/lifecycle.ts +6 -1
  109. package/src/resources/extensions/gsd/auto.ts +92 -49
  110. package/src/resources/extensions/gsd/dispatch-guard.ts +65 -0
  111. package/src/resources/extensions/gsd/docs/preferences-reference.md +76 -0
  112. package/src/resources/extensions/gsd/exit-command.ts +18 -0
  113. package/src/resources/extensions/gsd/files.ts +9 -40
  114. package/src/resources/extensions/gsd/git-service.ts +62 -17
  115. package/src/resources/extensions/gsd/gitignore.ts +28 -0
  116. package/src/resources/extensions/gsd/guided-flow.ts +49 -11
  117. package/src/resources/extensions/gsd/index.ts +111 -16
  118. package/src/resources/extensions/gsd/preferences.ts +8 -0
  119. package/src/resources/extensions/gsd/prompts/complete-milestone.md +2 -2
  120. package/src/resources/extensions/gsd/prompts/complete-slice.md +3 -3
  121. package/src/resources/extensions/gsd/prompts/discuss.md +27 -2
  122. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -2
  123. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -3
  124. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  125. package/src/resources/extensions/gsd/prompts/plan-slice.md +2 -2
  126. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +3 -3
  127. package/src/resources/extensions/gsd/prompts/replan-slice.md +2 -2
  128. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  129. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  130. package/src/resources/extensions/gsd/prompts/run-uat.md +4 -4
  131. package/src/resources/extensions/gsd/roadmap-slices.ts +50 -0
  132. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +102 -0
  133. package/src/resources/extensions/gsd/tests/exit-command.test.ts +50 -0
  134. package/src/resources/extensions/gsd/tests/git-service.test.ts +116 -39
  135. package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +5 -5
  136. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +2 -1
  137. package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +59 -0
  138. package/src/resources/extensions/gsd/tests/run-uat.test.ts +2 -4
  139. package/src/resources/extensions/gsd/tests/write-gate.test.ts +122 -0
  140. package/src/resources/extensions/ttsr/index.ts +163 -0
  141. package/src/resources/extensions/ttsr/rule-loader.ts +121 -0
  142. package/src/resources/extensions/ttsr/ttsr-interrupt.md +6 -0
  143. package/src/resources/extensions/ttsr/ttsr-manager.ts +344 -0
@@ -226,7 +226,6 @@ async function main(): Promise<void> {
226
226
  const milestoneId = 'M001';
227
227
  const sliceId = 'S01';
228
228
  const uatPath = '.gsd/milestones/M001/slices/S01/S01-UAT.md';
229
- const uatResultAbsPath = '/tmp/gsd-test/S01-UAT-RESULT.md';
230
229
  const uatResultPath = '.gsd/milestones/M001/slices/S01/S01-UAT-RESULT.md';
231
230
  const uatType = 'artifact-driven';
232
231
  const inlinedContext = '<!-- no context -->';
@@ -238,7 +237,6 @@ async function main(): Promise<void> {
238
237
  milestoneId,
239
238
  sliceId,
240
239
  uatPath,
241
- uatResultAbsPath,
242
240
  uatResultPath,
243
241
  uatType,
244
242
  inlinedContext,
@@ -261,8 +259,8 @@ async function main(): Promise<void> {
261
259
  `prompt contains sliceId value "${sliceId}" after substitution`,
262
260
  );
263
261
  assert(
264
- promptResult?.includes(uatResultAbsPath) ?? false,
265
- `prompt contains uatResultAbsPath value after substitution`,
262
+ promptResult?.includes(uatResultPath) ?? false,
263
+ `prompt contains uatResultPath value after substitution`,
266
264
  );
267
265
  assert(
268
266
  !/\{\{[^}]+\}\}/.test(promptResult ?? ''),
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Unit tests for the CONTEXT.md write-gate (D031 guard chain).
3
+ *
4
+ * Exercises shouldBlockContextWrite() — a pure function that implements:
5
+ * (a) toolName !== "write" → pass
6
+ * (b) milestoneId null → pass (not in discussion)
7
+ * (c) path doesn't match /M\d+-CONTEXT\.md$/ → pass
8
+ * (d) depthVerified → pass
9
+ * (e) else → block with actionable reason
10
+ */
11
+
12
+ import test from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { shouldBlockContextWrite } from '../index.ts';
15
+
16
+ // ─── Scenario 1: Blocks CONTEXT.md write during discussion without depth verification (absolute path) ──
17
+
18
+ test('write-gate: blocks CONTEXT.md write during discussion without depth verification (absolute path)', () => {
19
+ const result = shouldBlockContextWrite(
20
+ 'write',
21
+ '/Users/dev/project/.gsd/milestones/M001/M001-CONTEXT.md',
22
+ 'M001',
23
+ false,
24
+ );
25
+ assert.strictEqual(result.block, true, 'should block the write');
26
+ assert.ok(result.reason, 'should provide a reason');
27
+ });
28
+
29
+ // ─── Scenario 2: Blocks CONTEXT.md write during discussion without depth verification (relative path) ──
30
+
31
+ test('write-gate: blocks CONTEXT.md write during discussion without depth verification (relative path)', () => {
32
+ const result = shouldBlockContextWrite(
33
+ 'write',
34
+ '.gsd/milestones/M005/M005-CONTEXT.md',
35
+ 'M005',
36
+ false,
37
+ );
38
+ assert.strictEqual(result.block, true, 'should block the write');
39
+ assert.ok(result.reason, 'should provide a reason');
40
+ });
41
+
42
+ // ─── Scenario 3: Allows CONTEXT.md write after depth verification ──
43
+
44
+ test('write-gate: allows CONTEXT.md write after depth verification', () => {
45
+ const result = shouldBlockContextWrite(
46
+ 'write',
47
+ '/Users/dev/project/.gsd/milestones/M001/M001-CONTEXT.md',
48
+ 'M001',
49
+ true,
50
+ );
51
+ assert.strictEqual(result.block, false, 'should not block after depth verification');
52
+ assert.strictEqual(result.reason, undefined, 'should have no reason');
53
+ });
54
+
55
+ // ─── Scenario 4: Allows CONTEXT.md write outside discussion phase (milestoneId null) ──
56
+
57
+ test('write-gate: allows CONTEXT.md write outside discussion phase', () => {
58
+ const result = shouldBlockContextWrite(
59
+ 'write',
60
+ '.gsd/milestones/M001/M001-CONTEXT.md',
61
+ null,
62
+ false,
63
+ );
64
+ assert.strictEqual(result.block, false, 'should not block outside discussion phase');
65
+ });
66
+
67
+ // ─── Scenario 5: Allows non-CONTEXT.md writes during discussion ──
68
+
69
+ test('write-gate: allows non-CONTEXT.md writes during discussion', () => {
70
+ // DISCUSSION.md
71
+ const r1 = shouldBlockContextWrite(
72
+ 'write',
73
+ '.gsd/milestones/M001/M001-DISCUSSION.md',
74
+ 'M001',
75
+ false,
76
+ );
77
+ assert.strictEqual(r1.block, false, 'DISCUSSION.md should pass');
78
+
79
+ // Slice file
80
+ const r2 = shouldBlockContextWrite(
81
+ 'write',
82
+ '.gsd/milestones/M001/slices/S01/S01-PLAN.md',
83
+ 'M001',
84
+ false,
85
+ );
86
+ assert.strictEqual(r2.block, false, 'slice plan should pass');
87
+
88
+ // Regular code file
89
+ const r3 = shouldBlockContextWrite(
90
+ 'write',
91
+ 'src/index.ts',
92
+ 'M001',
93
+ false,
94
+ );
95
+ assert.strictEqual(r3.block, false, 'regular code file should pass');
96
+ });
97
+
98
+ // ─── Scenario 6: Regex specificity — doesn't match S01-CONTEXT.md ──
99
+
100
+ test('write-gate: regex does not match slice context files (S01-CONTEXT.md)', () => {
101
+ const result = shouldBlockContextWrite(
102
+ 'write',
103
+ '.gsd/milestones/M001/slices/S01/S01-CONTEXT.md',
104
+ 'M001',
105
+ false,
106
+ );
107
+ assert.strictEqual(result.block, false, 'S01-CONTEXT.md should not be blocked');
108
+ });
109
+
110
+ // ─── Scenario 7: Error message contains actionable instruction ──
111
+
112
+ test('write-gate: blocked reason contains depth_verification keyword', () => {
113
+ const result = shouldBlockContextWrite(
114
+ 'write',
115
+ '.gsd/milestones/M999/M999-CONTEXT.md',
116
+ 'M999',
117
+ false,
118
+ );
119
+ assert.strictEqual(result.block, true);
120
+ assert.ok(result.reason!.includes('depth_verification'), 'reason should mention depth_verification question id');
121
+ assert.ok(result.reason!.includes('ask_user_questions'), 'reason should mention ask_user_questions tool');
122
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * TTSR Extension — Time Traveling Stream Rules
3
+ *
4
+ * Zero-context-cost guardrails that monitor streaming output against regex
5
+ * patterns. On match: abort stream, inject rule as system reminder, retry.
6
+ * Rules cost nothing until they fire.
7
+ *
8
+ * Hooks:
9
+ * session_start → load rules, populate manager
10
+ * turn_start → reset buffers
11
+ * message_update → check delta against rules, abort on match
12
+ * turn_end → increment message count
13
+ * agent_end → if pending violation, inject rule via sendMessage
14
+ */
15
+
16
+ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
17
+ import type { AssistantMessageEvent } from "@gsd/pi-ai";
18
+ import { readFileSync } from "node:fs";
19
+ import { join, dirname } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { TtsrManager, type Rule, type TtsrMatchContext } from "./ttsr-manager.js";
22
+ import { loadRules } from "./rule-loader.js";
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+
27
+ interface PendingViolation {
28
+ rules: Rule[];
29
+ }
30
+
31
+ function buildInterruptContent(rule: Rule): string {
32
+ const template = readFileSync(join(__dirname, "ttsr-interrupt.md"), "utf-8");
33
+ return template
34
+ .replace("{{name}}", rule.name)
35
+ .replace("{{path}}", rule.path)
36
+ .replace("{{content}}", rule.content);
37
+ }
38
+
39
+ /**
40
+ * Extract match context from an AssistantMessageEvent delta.
41
+ * Returns null for non-delta events.
42
+ */
43
+ function extractDeltaContext(
44
+ event: AssistantMessageEvent,
45
+ ): { delta: string; context: TtsrMatchContext } | null {
46
+ if (event.type === "text_delta") {
47
+ return {
48
+ delta: event.delta,
49
+ context: { source: "text", streamKey: "text" },
50
+ };
51
+ }
52
+ if (event.type === "thinking_delta") {
53
+ return {
54
+ delta: event.delta,
55
+ context: { source: "thinking", streamKey: "thinking" },
56
+ };
57
+ }
58
+ if (event.type === "toolcall_delta") {
59
+ // Extract tool name and file paths from the partial message
60
+ const partial = event.partial;
61
+ const contentBlock = partial?.content?.[event.contentIndex];
62
+ const toolName = contentBlock && "name" in contentBlock ? (contentBlock as any).name : undefined;
63
+
64
+ // Try to extract file paths from partial JSON arguments
65
+ const filePaths: string[] = [];
66
+ if (contentBlock && "partialJson" in contentBlock) {
67
+ const json = (contentBlock as any).partialJson as string | undefined;
68
+ if (json) {
69
+ // Look for file_path or path in partial JSON
70
+ const pathMatch = json.match(/"(?:file_path|path)"\s*:\s*"([^"]+)"/);
71
+ if (pathMatch) filePaths.push(pathMatch[1]);
72
+ }
73
+ }
74
+
75
+ return {
76
+ delta: event.delta,
77
+ context: {
78
+ source: "tool",
79
+ toolName,
80
+ filePaths: filePaths.length > 0 ? filePaths : undefined,
81
+ streamKey: `toolcall:${event.contentIndex}`,
82
+ },
83
+ };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export default function (pi: ExtensionAPI) {
89
+ let manager: TtsrManager | null = null;
90
+ let pendingViolation: PendingViolation | null = null;
91
+
92
+ // ── session_start: load rules, populate manager ─────────────────────
93
+ pi.on("session_start", async (_event, ctx) => {
94
+ const rules = loadRules(ctx.cwd);
95
+ if (rules.length === 0) {
96
+ manager = null;
97
+ return;
98
+ }
99
+
100
+ manager = new TtsrManager();
101
+ let loaded = 0;
102
+ for (const rule of rules) {
103
+ if (manager.addRule(rule)) loaded++;
104
+ }
105
+
106
+ if (loaded === 0) {
107
+ manager = null;
108
+ }
109
+ });
110
+
111
+ // ── turn_start: reset buffers ───────────────────────────────────────
112
+ pi.on("turn_start", async () => {
113
+ if (!manager) return;
114
+ manager.resetBuffer();
115
+ pendingViolation = null;
116
+ });
117
+
118
+ // ── message_update: check delta against rules ───────────────────────
119
+ pi.on("message_update", async (event, ctx) => {
120
+ if (!manager || !manager.hasRules()) return;
121
+ if (pendingViolation) return; // Already matched, waiting for agent_end
122
+
123
+ const extracted = extractDeltaContext(event.assistantMessageEvent);
124
+ if (!extracted) return;
125
+
126
+ const { delta, context } = extracted;
127
+ const matches = manager.checkDelta(delta, context);
128
+ if (matches.length === 0) return;
129
+
130
+ // Match found — set pending violation and abort
131
+ pendingViolation = { rules: matches };
132
+ manager.markInjected(matches);
133
+ ctx.abort();
134
+ });
135
+
136
+ // ── turn_end: increment message count ───────────────────────────────
137
+ pi.on("turn_end", async () => {
138
+ if (!manager) return;
139
+ manager.incrementMessageCount();
140
+ });
141
+
142
+ // ── agent_end: inject violation if pending ──────────────────────────
143
+ pi.on("agent_end", async () => {
144
+ if (!manager || !pendingViolation) return;
145
+
146
+ const violation = pendingViolation;
147
+ pendingViolation = null;
148
+
149
+ // Build interrupt content for all matching rules
150
+ const interruptParts = violation.rules.map(buildInterruptContent);
151
+ const fullInterrupt = interruptParts.join("\n\n");
152
+
153
+ // Inject as a message that triggers a new turn
154
+ pi.sendMessage(
155
+ {
156
+ customType: "ttsr-violation",
157
+ content: fullInterrupt,
158
+ display: false,
159
+ },
160
+ { triggerTurn: true },
161
+ );
162
+ });
163
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * TTSR Rule Loader
3
+ *
4
+ * Scans global (~/.gsd/agent/rules/*.md) and project-local (.gsd/rules/*.md)
5
+ * rule files. Parses YAML frontmatter for condition, scope, globs.
6
+ * Project rules override global rules with the same name.
7
+ */
8
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
9
+ import { join, basename } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import type { Rule } from "./ttsr-manager.js";
12
+
13
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
14
+
15
+ /** Minimal YAML parser for frontmatter (handles string arrays and scalars). */
16
+ function parseFrontmatter(raw: string): Record<string, unknown> {
17
+ const result: Record<string, unknown> = {};
18
+ let currentKey: string | null = null;
19
+ let currentArray: string[] | null = null;
20
+
21
+ for (const line of raw.split("\n")) {
22
+ const trimmed = line.trimEnd();
23
+
24
+ // Array item under current key
25
+ if (currentKey && /^\s+-\s+/.test(trimmed)) {
26
+ const value = trimmed.replace(/^\s+-\s+/, "").replace(/^["']|["']$/g, "");
27
+ currentArray!.push(value);
28
+ continue;
29
+ }
30
+
31
+ // Flush previous array
32
+ if (currentKey && currentArray) {
33
+ result[currentKey] = currentArray;
34
+ currentKey = null;
35
+ currentArray = null;
36
+ }
37
+
38
+ // Key-value or key-with-array
39
+ const kvMatch = trimmed.match(/^(\w[\w-]*):\s*(.*)$/);
40
+ if (kvMatch) {
41
+ const [, key, value] = kvMatch;
42
+ if (value.length === 0) {
43
+ // Expect array items below
44
+ currentKey = key;
45
+ currentArray = [];
46
+ } else {
47
+ result[key] = value.replace(/^["']|["']$/g, "");
48
+ }
49
+ }
50
+ }
51
+
52
+ // Flush trailing array
53
+ if (currentKey && currentArray) {
54
+ result[currentKey] = currentArray;
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ function parseRuleFile(filePath: string): Rule | null {
61
+ let content: string;
62
+ try {
63
+ content = readFileSync(filePath, "utf-8");
64
+ } catch {
65
+ return null;
66
+ }
67
+
68
+ const match = FRONTMATTER_RE.exec(content);
69
+ if (!match) return null;
70
+
71
+ const [, frontmatterRaw, body] = match;
72
+ const meta = parseFrontmatter(frontmatterRaw);
73
+
74
+ const condition = meta.condition;
75
+ if (!Array.isArray(condition) || condition.length === 0) return null;
76
+
77
+ const name = basename(filePath, ".md");
78
+
79
+ return {
80
+ name,
81
+ path: filePath,
82
+ content: body.trim(),
83
+ condition: condition as string[],
84
+ scope: Array.isArray(meta.scope) ? (meta.scope as string[]) : undefined,
85
+ globs: Array.isArray(meta.globs) ? (meta.globs as string[]) : undefined,
86
+ };
87
+ }
88
+
89
+ function scanDir(dir: string): Rule[] {
90
+ if (!existsSync(dir)) return [];
91
+ const rules: Rule[] = [];
92
+ try {
93
+ const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
94
+ for (const file of files) {
95
+ const rule = parseRuleFile(join(dir, file));
96
+ if (rule) rules.push(rule);
97
+ }
98
+ } catch {
99
+ // Directory unreadable — skip
100
+ }
101
+ return rules;
102
+ }
103
+
104
+ /**
105
+ * Load all TTSR rules from global and project-local directories.
106
+ * Project rules override global rules with the same name.
107
+ */
108
+ export function loadRules(cwd: string): Rule[] {
109
+ const globalDir = join(homedir(), ".gsd", "agent", "rules");
110
+ const projectDir = join(cwd, ".gsd", "rules");
111
+
112
+ const globalRules = scanDir(globalDir);
113
+ const projectRules = scanDir(projectDir);
114
+
115
+ // Merge: project rules override global by name
116
+ const byName = new Map<string, Rule>();
117
+ for (const rule of globalRules) byName.set(rule.name, rule);
118
+ for (const rule of projectRules) byName.set(rule.name, rule);
119
+
120
+ return Array.from(byName.values());
121
+ }
@@ -0,0 +1,6 @@
1
+ <system-interrupt reason="rule_violation" rule="{{name}}" path="{{path}}">
2
+ Your output was interrupted because it violated a project rule.
3
+ You MUST comply with the following instruction:
4
+
5
+ {{content}}
6
+ </system-interrupt>