gsd-pi 2.7.1 → 2.8.1

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 (133) hide show
  1. package/README.md +12 -5
  2. package/dist/loader.js +0 -0
  3. package/dist/modes/interactive/theme/dark.json +85 -0
  4. package/dist/modes/interactive/theme/light.json +84 -0
  5. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  6. package/dist/modes/interactive/theme/theme.d.ts +78 -0
  7. package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  8. package/dist/modes/interactive/theme/theme.js +949 -0
  9. package/dist/modes/interactive/theme/theme.js.map +1 -0
  10. package/node_modules/@gsd/pi-coding-agent/dist/config.d.ts +2 -0
  11. package/node_modules/@gsd/pi-coding-agent/dist/config.d.ts.map +1 -1
  12. package/node_modules/@gsd/pi-coding-agent/dist/config.js +4 -0
  13. package/node_modules/@gsd/pi-coding-agent/dist/config.js.map +1 -1
  14. package/node_modules/@gsd/pi-coding-agent/dist/core/artifact-manager.d.ts +52 -0
  15. package/node_modules/@gsd/pi-coding-agent/dist/core/artifact-manager.d.ts.map +1 -0
  16. package/node_modules/@gsd/pi-coding-agent/dist/core/artifact-manager.js +117 -0
  17. package/node_modules/@gsd/pi-coding-agent/dist/core/artifact-manager.js.map +1 -0
  18. package/node_modules/@gsd/pi-coding-agent/dist/core/blob-store.d.ts +31 -0
  19. package/node_modules/@gsd/pi-coding-agent/dist/core/blob-store.d.ts.map +1 -0
  20. package/node_modules/@gsd/pi-coding-agent/dist/core/blob-store.js +97 -0
  21. package/node_modules/@gsd/pi-coding-agent/dist/core/blob-store.js.map +1 -0
  22. package/node_modules/@gsd/pi-coding-agent/dist/core/session-manager.d.ts +1 -0
  23. package/node_modules/@gsd/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  24. package/node_modules/@gsd/pi-coding-agent/dist/core/session-manager.js +112 -3
  25. package/node_modules/@gsd/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  26. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts +4 -0
  27. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  28. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js +30 -20
  29. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  30. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts +2 -0
  31. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts.map +1 -1
  32. package/node_modules/@gsd/pi-coding-agent/dist/index.js +3 -0
  33. package/node_modules/@gsd/pi-coding-agent/dist/index.js.map +1 -1
  34. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  35. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  36. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  37. package/node_modules/@gsd/pi-coding-agent/src/config.ts +5 -0
  38. package/node_modules/@gsd/pi-coding-agent/src/core/artifact-manager.ts +125 -0
  39. package/node_modules/@gsd/pi-coding-agent/src/core/blob-store.ts +106 -0
  40. package/node_modules/@gsd/pi-coding-agent/src/core/session-manager.ts +119 -3
  41. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash.ts +33 -20
  42. package/node_modules/@gsd/pi-coding-agent/src/index.ts +3 -0
  43. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  44. package/node_modules/cliui/CHANGELOG.md +121 -0
  45. package/node_modules/color-convert/CHANGELOG.md +54 -0
  46. package/node_modules/esprima/ChangeLog +235 -0
  47. package/node_modules/mz/HISTORY.md +66 -0
  48. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  49. package/node_modules/source-map/CHANGELOG.md +301 -0
  50. package/node_modules/thenify/History.md +11 -0
  51. package/node_modules/thenify-all/History.md +11 -0
  52. package/node_modules/y18n/CHANGELOG.md +100 -0
  53. package/node_modules/yargs/CHANGELOG.md +88 -0
  54. package/node_modules/yargs-parser/CHANGELOG.md +263 -0
  55. package/package.json +10 -2
  56. package/packages/pi-coding-agent/dist/config.d.ts +2 -0
  57. package/packages/pi-coding-agent/dist/config.d.ts.map +1 -1
  58. package/packages/pi-coding-agent/dist/config.js +4 -0
  59. package/packages/pi-coding-agent/dist/config.js.map +1 -1
  60. package/packages/pi-coding-agent/dist/core/artifact-manager.d.ts +52 -0
  61. package/packages/pi-coding-agent/dist/core/artifact-manager.d.ts.map +1 -0
  62. package/packages/pi-coding-agent/dist/core/artifact-manager.js +117 -0
  63. package/packages/pi-coding-agent/dist/core/artifact-manager.js.map +1 -0
  64. package/packages/pi-coding-agent/dist/core/blob-store.d.ts +31 -0
  65. package/packages/pi-coding-agent/dist/core/blob-store.d.ts.map +1 -0
  66. package/packages/pi-coding-agent/dist/core/blob-store.js +97 -0
  67. package/packages/pi-coding-agent/dist/core/blob-store.js.map +1 -0
  68. package/packages/pi-coding-agent/dist/core/session-manager.d.ts +1 -0
  69. package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/session-manager.js +112 -3
  71. package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +4 -0
  73. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  74. package/packages/pi-coding-agent/dist/core/tools/bash.js +30 -20
  75. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  76. package/packages/pi-coding-agent/dist/index.d.ts +2 -0
  77. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  78. package/packages/pi-coding-agent/dist/index.js +3 -0
  79. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  80. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  82. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  83. package/packages/pi-coding-agent/src/config.ts +5 -0
  84. package/packages/pi-coding-agent/src/core/artifact-manager.ts +125 -0
  85. package/packages/pi-coding-agent/src/core/blob-store.ts +106 -0
  86. package/packages/pi-coding-agent/src/core/session-manager.ts +119 -3
  87. package/packages/pi-coding-agent/src/core/tools/bash.ts +33 -20
  88. package/packages/pi-coding-agent/src/index.ts +3 -0
  89. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  90. package/src/resources/extensions/browser-tools/capture.ts +165 -0
  91. package/src/resources/extensions/browser-tools/evaluate-helpers.ts +184 -0
  92. package/src/resources/extensions/browser-tools/index.ts +47 -4985
  93. package/src/resources/extensions/browser-tools/lifecycle.ts +270 -0
  94. package/src/resources/extensions/browser-tools/package.json +5 -1
  95. package/src/resources/extensions/browser-tools/refs.ts +264 -0
  96. package/src/resources/extensions/browser-tools/settle.ts +197 -0
  97. package/src/resources/extensions/browser-tools/state.ts +408 -0
  98. package/src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs +652 -0
  99. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +614 -0
  100. package/src/resources/extensions/browser-tools/tools/assertions.ts +342 -0
  101. package/src/resources/extensions/browser-tools/tools/forms.ts +801 -0
  102. package/src/resources/extensions/browser-tools/tools/inspection.ts +492 -0
  103. package/src/resources/extensions/browser-tools/tools/intent.ts +614 -0
  104. package/src/resources/extensions/browser-tools/tools/interaction.ts +865 -0
  105. package/src/resources/extensions/browser-tools/tools/navigation.ts +232 -0
  106. package/src/resources/extensions/browser-tools/tools/pages.ts +303 -0
  107. package/src/resources/extensions/browser-tools/tools/refs.ts +541 -0
  108. package/src/resources/extensions/browser-tools/tools/screenshot.ts +83 -0
  109. package/src/resources/extensions/browser-tools/tools/session.ts +400 -0
  110. package/src/resources/extensions/browser-tools/tools/wait.ts +247 -0
  111. package/src/resources/extensions/browser-tools/utils.ts +660 -0
  112. package/src/resources/extensions/gsd/auto.ts +74 -13
  113. package/src/resources/extensions/gsd/dispatch-guard.ts +65 -0
  114. package/src/resources/extensions/gsd/exit-command.ts +18 -0
  115. package/src/resources/extensions/gsd/files.ts +9 -40
  116. package/src/resources/extensions/gsd/git-service.ts +65 -17
  117. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  118. package/src/resources/extensions/gsd/guided-flow.ts +39 -2
  119. package/src/resources/extensions/gsd/index.ts +111 -16
  120. package/src/resources/extensions/gsd/preferences.ts +8 -0
  121. package/src/resources/extensions/gsd/prompts/complete-slice.md +2 -2
  122. package/src/resources/extensions/gsd/prompts/discuss.md +25 -0
  123. package/src/resources/extensions/gsd/roadmap-slices.ts +50 -0
  124. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +102 -0
  125. package/src/resources/extensions/gsd/tests/exit-command.test.ts +50 -0
  126. package/src/resources/extensions/gsd/tests/git-service.test.ts +64 -39
  127. package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +59 -0
  128. package/src/resources/extensions/gsd/tests/write-gate.test.ts +122 -0
  129. package/src/resources/extensions/shared/interview-ui.ts +1 -1
  130. package/src/resources/extensions/ttsr/index.ts +163 -0
  131. package/src/resources/extensions/ttsr/rule-loader.ts +121 -0
  132. package/src/resources/extensions/ttsr/ttsr-interrupt.md +6 -0
  133. package/src/resources/extensions/ttsr/ttsr-manager.ts +344 -0
@@ -0,0 +1,59 @@
1
+ import { parseRoadmap } from "../files.ts";
2
+ import { parseRoadmapSlices } from "../roadmap-slices.ts";
3
+
4
+ let passed = 0;
5
+ let failed = 0;
6
+
7
+ function assert(condition: boolean, message: string): void {
8
+ if (condition) passed += 1;
9
+ else {
10
+ failed += 1;
11
+ console.error(`FAIL: ${message}`);
12
+ }
13
+ }
14
+
15
+ function assertEq<T>(actual: T, expected: T, message: string): void {
16
+ if (JSON.stringify(actual) === JSON.stringify(expected)) passed += 1;
17
+ else {
18
+ failed += 1;
19
+ console.error(`FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
20
+ }
21
+ }
22
+
23
+ const content = `# M003: Current
24
+
25
+ **Vision:** Build the thing.
26
+
27
+ ## Slices
28
+ - [x] **S01: First Slice** \`risk:low\` \`depends:[]\`
29
+ > After this: First demo works.
30
+ - [ ] **S02: Second Slice** \`risk:medium\` \`depends:[S01]\`
31
+ - [x] **S03: Third Slice** \`depends:[S01, S02]\`
32
+ > After this: Third demo works.
33
+
34
+ ## Boundary Map
35
+ ### S01 → S02
36
+ Produces:
37
+ foo.ts
38
+ `;
39
+
40
+ console.log("\n=== parseRoadmapSlices ===");
41
+ const slices = parseRoadmapSlices(content);
42
+ assertEq(slices.length, 3, "slice count");
43
+ assertEq(slices[0]?.id, "S01", "first id");
44
+ assertEq(slices[0]?.done, true, "first done");
45
+ assertEq(slices[0]?.demo, "First demo works.", "first demo");
46
+ assertEq(slices[1]?.depends, ["S01"], "second depends");
47
+ assertEq(slices[1]?.risk, "medium", "second risk");
48
+ assertEq(slices[2]?.risk, "low", "missing risk defaults to low");
49
+ assertEq(slices[2]?.depends, ["S01", "S02"], "third depends");
50
+
51
+ console.log("\n=== parseRoadmap integration ===");
52
+ const roadmap = parseRoadmap(content);
53
+ assertEq(roadmap.slices, slices, "parseRoadmap uses extracted slice parser");
54
+ assertEq(roadmap.title, "M003: Current", "roadmap title preserved");
55
+ assertEq(roadmap.vision, "Build the thing.", "roadmap vision preserved");
56
+ assert(roadmap.boundaryMap.length === 1, "boundary map still parsed");
57
+
58
+ console.log(`Passed: ${passed}, Failed: ${failed}`);
59
+ if (failed > 0) process.exit(1);
@@ -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
+ });
@@ -235,7 +235,7 @@ export async function showInterviewRound(
235
235
  }
236
236
 
237
237
  function saveEditorToState() {
238
- states[currentIdx].notes = getEditor().getText().trim();
238
+ states[currentIdx].notes = getEditor().getExpandedText().trim();
239
239
  }
240
240
 
241
241
  function loadStateToEditor() {
@@ -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>