tack-cli 0.1.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/dist/App.d.ts +5 -0
  4. package/dist/App.js +17 -0
  5. package/dist/detectors/admin.d.ts +2 -0
  6. package/dist/detectors/admin.js +33 -0
  7. package/dist/detectors/auth.d.ts +2 -0
  8. package/dist/detectors/auth.js +86 -0
  9. package/dist/detectors/database.d.ts +2 -0
  10. package/dist/detectors/database.js +96 -0
  11. package/dist/detectors/duplicates.d.ts +2 -0
  12. package/dist/detectors/duplicates.js +23 -0
  13. package/dist/detectors/exports.d.ts +2 -0
  14. package/dist/detectors/exports.js +30 -0
  15. package/dist/detectors/framework.d.ts +2 -0
  16. package/dist/detectors/framework.js +71 -0
  17. package/dist/detectors/index.d.ts +12 -0
  18. package/dist/detectors/index.js +128 -0
  19. package/dist/detectors/jobs.d.ts +2 -0
  20. package/dist/detectors/jobs.js +62 -0
  21. package/dist/detectors/multiuser.d.ts +2 -0
  22. package/dist/detectors/multiuser.js +55 -0
  23. package/dist/detectors/payments.d.ts +2 -0
  24. package/dist/detectors/payments.js +49 -0
  25. package/dist/detectors/rules/auth.yaml +24 -0
  26. package/dist/detectors/rules/database.yaml +27 -0
  27. package/dist/detectors/rules/exports.yaml +28 -0
  28. package/dist/detectors/rules/framework.yaml +26 -0
  29. package/dist/detectors/rules/jobs.yaml +23 -0
  30. package/dist/detectors/rules/payments.yaml +22 -0
  31. package/dist/detectors/types.d.ts +2 -0
  32. package/dist/detectors/types.js +1 -0
  33. package/dist/detectors/yamlRunner.d.ts +31 -0
  34. package/dist/detectors/yamlRunner.js +128 -0
  35. package/dist/engine/cleanup.d.ts +12 -0
  36. package/dist/engine/cleanup.js +101 -0
  37. package/dist/engine/compaction.d.ts +5 -0
  38. package/dist/engine/compaction.js +44 -0
  39. package/dist/engine/compareSpec.d.ts +2 -0
  40. package/dist/engine/compareSpec.js +74 -0
  41. package/dist/engine/computeDrift.d.ts +6 -0
  42. package/dist/engine/computeDrift.js +133 -0
  43. package/dist/engine/contextPack.d.ts +4 -0
  44. package/dist/engine/contextPack.js +169 -0
  45. package/dist/engine/decisions.d.ts +4 -0
  46. package/dist/engine/decisions.js +21 -0
  47. package/dist/engine/diff.d.ts +46 -0
  48. package/dist/engine/diff.js +210 -0
  49. package/dist/engine/handoff.d.ts +7 -0
  50. package/dist/engine/handoff.js +469 -0
  51. package/dist/engine/status.d.ts +10 -0
  52. package/dist/engine/status.js +46 -0
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +299 -0
  55. package/dist/lib/cli.d.ts +4 -0
  56. package/dist/lib/cli.js +8 -0
  57. package/dist/lib/files.d.ts +48 -0
  58. package/dist/lib/files.js +529 -0
  59. package/dist/lib/git.d.ts +9 -0
  60. package/dist/lib/git.js +96 -0
  61. package/dist/lib/logger.d.ts +3 -0
  62. package/dist/lib/logger.js +21 -0
  63. package/dist/lib/ndjson.d.ts +2 -0
  64. package/dist/lib/ndjson.js +45 -0
  65. package/dist/lib/notes.d.ts +8 -0
  66. package/dist/lib/notes.js +144 -0
  67. package/dist/lib/notify.d.ts +1 -0
  68. package/dist/lib/notify.js +14 -0
  69. package/dist/lib/project.d.ts +1 -0
  70. package/dist/lib/project.js +17 -0
  71. package/dist/lib/promptSafety.d.ts +1 -0
  72. package/dist/lib/promptSafety.js +20 -0
  73. package/dist/lib/signals.d.ts +279 -0
  74. package/dist/lib/signals.js +55 -0
  75. package/dist/lib/tty.d.ts +2 -0
  76. package/dist/lib/tty.js +10 -0
  77. package/dist/lib/validate.d.ts +9 -0
  78. package/dist/lib/validate.js +282 -0
  79. package/dist/lib/yaml.d.ts +4 -0
  80. package/dist/lib/yaml.js +26 -0
  81. package/dist/mcp.d.ts +1 -0
  82. package/dist/mcp.js +259 -0
  83. package/dist/plain/colors.d.ts +5 -0
  84. package/dist/plain/colors.js +16 -0
  85. package/dist/plain/diff.d.ts +1 -0
  86. package/dist/plain/diff.js +129 -0
  87. package/dist/plain/handoff.d.ts +1 -0
  88. package/dist/plain/handoff.js +9 -0
  89. package/dist/plain/init.d.ts +1 -0
  90. package/dist/plain/init.js +44 -0
  91. package/dist/plain/notes.d.ts +5 -0
  92. package/dist/plain/notes.js +49 -0
  93. package/dist/plain/status.d.ts +2 -0
  94. package/dist/plain/status.js +13 -0
  95. package/dist/plain/watch.d.ts +1 -0
  96. package/dist/plain/watch.js +78 -0
  97. package/dist/ui/CleanupPlan.d.ts +5 -0
  98. package/dist/ui/CleanupPlan.js +8 -0
  99. package/dist/ui/DetectorSweep.d.ts +6 -0
  100. package/dist/ui/DetectorSweep.js +54 -0
  101. package/dist/ui/DriftAlert.d.ts +7 -0
  102. package/dist/ui/DriftAlert.js +105 -0
  103. package/dist/ui/Handoff.d.ts +1 -0
  104. package/dist/ui/Handoff.js +37 -0
  105. package/dist/ui/Init.d.ts +1 -0
  106. package/dist/ui/Init.js +117 -0
  107. package/dist/ui/Logo.d.ts +1 -0
  108. package/dist/ui/Logo.js +13 -0
  109. package/dist/ui/SpecSummary.d.ts +8 -0
  110. package/dist/ui/SpecSummary.js +15 -0
  111. package/dist/ui/Status.d.ts +1 -0
  112. package/dist/ui/Status.js +38 -0
  113. package/dist/ui/Watch.d.ts +1 -0
  114. package/dist/ui/Watch.js +136 -0
  115. package/dist/yoga.wasm +0 -0
  116. package/package.json +50 -0
@@ -0,0 +1,282 @@
1
+ import * as path from "node:path";
2
+ import { KNOWN_CONSTRAINT_KEYS } from "./signals.js";
3
+ const MAX_FIELD_LENGTH = 200;
4
+ const MAX_SOURCE_LENGTH = 500;
5
+ const SPEC_KEYS = new Set(["project", "allowed_systems", "forbidden_systems", "constraints", "domains"]);
6
+ const DRIFT_TYPES = new Set([
7
+ "forbidden_system_detected",
8
+ "constraint_mismatch",
9
+ "risk",
10
+ "undeclared_system",
11
+ ]);
12
+ const DRIFT_STATUS = new Set(["unresolved", "accepted", "rejected"]);
13
+ const KNOWN_CONSTRAINTS = new Set(KNOWN_CONSTRAINT_KEYS);
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function cleanString(input, field, warnings, max = MAX_FIELD_LENGTH) {
18
+ const stripped = input.replace(/[\n\r\t\x00-\x1f]/g, "").trim();
19
+ if (stripped !== input) {
20
+ warnings.push(`Suspicious characters stripped from ${field}`);
21
+ }
22
+ if (stripped.length > max) {
23
+ warnings.push(`Field ${field} exceeded ${max} chars and was truncated`);
24
+ return stripped.slice(0, max);
25
+ }
26
+ return stripped;
27
+ }
28
+ function sanitizeStringArray(value, field, warnings, max = MAX_FIELD_LENGTH) {
29
+ if (!Array.isArray(value)) {
30
+ if (value !== undefined)
31
+ warnings.push(`Expected array for ${field}, got ${typeof value}`);
32
+ return [];
33
+ }
34
+ return value
35
+ .flatMap((item) => {
36
+ if (typeof item !== "string") {
37
+ warnings.push(`Non-string value in ${field} skipped`);
38
+ return [];
39
+ }
40
+ const cleaned = cleanString(item, field, warnings, max);
41
+ return cleaned ? [cleaned] : [];
42
+ })
43
+ .filter((v, i, arr) => arr.indexOf(v) === i);
44
+ }
45
+ export function validateSpec(raw, projectRoot) {
46
+ const warnings = [];
47
+ if (!isRecord(raw)) {
48
+ if (raw !== null && raw !== undefined) {
49
+ warnings.push("spec.yaml root must be a mapping/object");
50
+ }
51
+ return { data: null, warnings };
52
+ }
53
+ for (const key of Object.keys(raw)) {
54
+ if (!SPEC_KEYS.has(key))
55
+ warnings.push(`Unknown key "${key}" in spec.yaml ignored`);
56
+ }
57
+ const fallbackProject = path.basename(projectRoot);
58
+ const project = typeof raw.project === "string"
59
+ ? cleanString(raw.project, "project", warnings)
60
+ : fallbackProject;
61
+ const allowed = sanitizeStringArray(raw.allowed_systems, "allowed_systems", warnings);
62
+ const forbidden = sanitizeStringArray(raw.forbidden_systems, "forbidden_systems", warnings);
63
+ const constraints = {};
64
+ if (raw.constraints !== undefined && !isRecord(raw.constraints)) {
65
+ warnings.push("constraints must be an object map and was reset");
66
+ }
67
+ else if (isRecord(raw.constraints)) {
68
+ for (const [key, value] of Object.entries(raw.constraints)) {
69
+ if (!KNOWN_CONSTRAINTS.has(key)) {
70
+ warnings.push(`Unknown constraint key "${key}" ignored`);
71
+ continue;
72
+ }
73
+ if (typeof value !== "string") {
74
+ warnings.push(`Constraint "${key}" must be a string and was ignored`);
75
+ continue;
76
+ }
77
+ const cleaned = cleanString(value, `constraints.${key}`, warnings);
78
+ if (!cleaned) {
79
+ warnings.push(`Constraint "${key}" was empty after sanitization and removed`);
80
+ continue;
81
+ }
82
+ constraints[key] = cleaned;
83
+ }
84
+ }
85
+ let domains;
86
+ if (raw.domains !== undefined) {
87
+ if (!isRecord(raw.domains)) {
88
+ warnings.push("domains must be an object map and was ignored");
89
+ }
90
+ else {
91
+ const domainEntries = {};
92
+ for (const [rawId, rawDomain] of Object.entries(raw.domains)) {
93
+ const id = cleanString(String(rawId), "domains.key", warnings);
94
+ if (!id) {
95
+ warnings.push("Empty domain key after sanitization was skipped");
96
+ continue;
97
+ }
98
+ if (!isRecord(rawDomain)) {
99
+ warnings.push(`Domain "${id}" must be an object and was skipped`);
100
+ continue;
101
+ }
102
+ const domain = {};
103
+ if (typeof rawDomain.label === "string") {
104
+ const label = cleanString(rawDomain.label, `domains.${id}.label`, warnings);
105
+ if (label)
106
+ domain.label = label;
107
+ }
108
+ if (rawDomain.systems !== undefined) {
109
+ const systems = sanitizeStringArray(rawDomain.systems, `domains.${id}.systems`, warnings);
110
+ if (systems.length > 0)
111
+ domain.systems = systems;
112
+ }
113
+ if (rawDomain.constraints !== undefined) {
114
+ const rawList = sanitizeStringArray(rawDomain.constraints, `domains.${id}.constraints`, warnings);
115
+ const filtered = rawList.filter((c) => {
116
+ if (!KNOWN_CONSTRAINTS.has(c)) {
117
+ warnings.push(`Unknown constraint key "${c}" in domains.${id}.constraints ignored`);
118
+ return false;
119
+ }
120
+ return true;
121
+ });
122
+ if (filtered.length > 0)
123
+ domain.constraints = filtered;
124
+ }
125
+ if (Object.keys(domain).length === 0) {
126
+ warnings.push(`Domain "${id}" was empty after sanitization and removed`);
127
+ continue;
128
+ }
129
+ domainEntries[id] = domain;
130
+ }
131
+ if (Object.keys(domainEntries).length > 0) {
132
+ domains = domainEntries;
133
+ }
134
+ }
135
+ }
136
+ return {
137
+ data: {
138
+ project: project || fallbackProject,
139
+ allowed_systems: allowed,
140
+ forbidden_systems: forbidden,
141
+ constraints,
142
+ ...(domains ? { domains } : {}),
143
+ },
144
+ warnings,
145
+ };
146
+ }
147
+ function validateSignal(raw, bucket, warnings) {
148
+ if (!isRecord(raw)) {
149
+ warnings.push(`Invalid signal entry in ${bucket} list skipped`);
150
+ return null;
151
+ }
152
+ if (typeof raw.id !== "string" || typeof raw.source !== "string") {
153
+ warnings.push(`Signal in ${bucket} list missing id/source and was skipped`);
154
+ return null;
155
+ }
156
+ if (typeof raw.confidence !== "number" || Number.isNaN(raw.confidence)) {
157
+ warnings.push(`Signal ${raw.id} has invalid confidence and was skipped`);
158
+ return null;
159
+ }
160
+ if (raw.confidence < 0 || raw.confidence > 1) {
161
+ warnings.push(`Signal ${raw.id} confidence was clamped to 0-1`);
162
+ }
163
+ const signal = {
164
+ category: bucket,
165
+ id: cleanString(raw.id, `signal.${bucket}.id`, warnings),
166
+ source: cleanString(raw.source, `signal.${bucket}.source`, warnings, MAX_SOURCE_LENGTH),
167
+ confidence: Math.max(0, Math.min(1, raw.confidence)),
168
+ };
169
+ if (typeof raw.detail === "string") {
170
+ const detail = cleanString(raw.detail, `signal.${bucket}.detail`, warnings, MAX_SOURCE_LENGTH);
171
+ if (detail)
172
+ signal.detail = detail;
173
+ }
174
+ if (!signal.id || !signal.source) {
175
+ warnings.push(`Signal in ${bucket} list became empty after sanitization and was skipped`);
176
+ return null;
177
+ }
178
+ return signal;
179
+ }
180
+ export function validateAudit(raw) {
181
+ const warnings = [];
182
+ if (!isRecord(raw)) {
183
+ if (raw !== null && raw !== undefined)
184
+ warnings.push("_audit.yaml root must be an object");
185
+ return { data: null, warnings };
186
+ }
187
+ if (!isRecord(raw.signals)) {
188
+ warnings.push("_audit.yaml missing signals object");
189
+ return { data: null, warnings };
190
+ }
191
+ const systems = Array.isArray(raw.signals.systems)
192
+ ? raw.signals.systems.map((s) => validateSignal(s, "system", warnings)).filter((s) => s !== null)
193
+ : [];
194
+ const scopeSignals = Array.isArray(raw.signals.scope_signals)
195
+ ? raw.signals.scope_signals
196
+ .map((s) => validateSignal(s, "scope", warnings))
197
+ .filter((s) => s !== null)
198
+ : [];
199
+ const risks = Array.isArray(raw.signals.risks)
200
+ ? raw.signals.risks.map((s) => validateSignal(s, "risk", warnings)).filter((s) => s !== null)
201
+ : [];
202
+ if (!Array.isArray(raw.signals.systems))
203
+ warnings.push("_audit.yaml signals.systems was reset");
204
+ if (!Array.isArray(raw.signals.scope_signals))
205
+ warnings.push("_audit.yaml signals.scope_signals was reset");
206
+ if (!Array.isArray(raw.signals.risks))
207
+ warnings.push("_audit.yaml signals.risks was reset");
208
+ const timestamp = typeof raw.timestamp === "string" && raw.timestamp.trim()
209
+ ? cleanString(raw.timestamp, "audit.timestamp", warnings, MAX_SOURCE_LENGTH)
210
+ : new Date().toISOString();
211
+ return {
212
+ data: {
213
+ timestamp,
214
+ signals: {
215
+ systems,
216
+ scope_signals: scopeSignals,
217
+ risks,
218
+ },
219
+ },
220
+ warnings,
221
+ };
222
+ }
223
+ function validateDriftItem(raw, warnings) {
224
+ if (!isRecord(raw)) {
225
+ warnings.push("Invalid drift item skipped");
226
+ return null;
227
+ }
228
+ if (typeof raw.id !== "string" || typeof raw.signal !== "string") {
229
+ warnings.push("Drift item missing id/signal skipped");
230
+ return null;
231
+ }
232
+ if (typeof raw.type !== "string" || !DRIFT_TYPES.has(raw.type)) {
233
+ warnings.push(`Drift item ${raw.id} has unknown type and was skipped`);
234
+ return null;
235
+ }
236
+ const status = typeof raw.status === "string" && DRIFT_STATUS.has(raw.status)
237
+ ? raw.status
238
+ : "unresolved";
239
+ if (status !== raw.status)
240
+ warnings.push(`Drift item ${raw.id} had invalid status and defaulted to unresolved`);
241
+ const detected = typeof raw.detected === "string" && raw.detected.trim()
242
+ ? cleanString(raw.detected, `drift.${raw.id}.detected`, warnings, MAX_SOURCE_LENGTH)
243
+ : new Date().toISOString();
244
+ const item = {
245
+ id: cleanString(raw.id, "drift.id", warnings),
246
+ type: raw.type,
247
+ signal: cleanString(raw.signal, "drift.signal", warnings, MAX_SOURCE_LENGTH),
248
+ detected,
249
+ status,
250
+ };
251
+ if (typeof raw.system === "string")
252
+ item.system = cleanString(raw.system, "drift.system", warnings);
253
+ if (typeof raw.risk === "string")
254
+ item.risk = cleanString(raw.risk, "drift.risk", warnings);
255
+ if (typeof raw.constraint === "string") {
256
+ item.constraint = cleanString(raw.constraint, "drift.constraint", warnings);
257
+ }
258
+ if (typeof raw.note === "string")
259
+ item.note = cleanString(raw.note, "drift.note", warnings, MAX_SOURCE_LENGTH);
260
+ if (!item.id || !item.signal) {
261
+ warnings.push("Drift item became empty after sanitization and was skipped");
262
+ return null;
263
+ }
264
+ return item;
265
+ }
266
+ export function validateDriftState(raw) {
267
+ const warnings = [];
268
+ if (!isRecord(raw)) {
269
+ if (raw !== null && raw !== undefined)
270
+ warnings.push("_drift.yaml root must be an object");
271
+ return { data: { items: [] }, warnings };
272
+ }
273
+ if (!Array.isArray(raw.items)) {
274
+ if (raw.items !== undefined)
275
+ warnings.push("_drift.yaml items must be an array");
276
+ return { data: { items: [] }, warnings };
277
+ }
278
+ const items = raw.items
279
+ .map((item) => validateDriftItem(item, warnings))
280
+ .filter((item) => item !== null);
281
+ return { data: { items }, warnings };
282
+ }
@@ -0,0 +1,4 @@
1
+ export declare function safeLoadYaml<T>(filepath: string, fallback: T): {
2
+ data: T;
3
+ error: string | null;
4
+ };
@@ -0,0 +1,26 @@
1
+ import { basename } from "node:path";
2
+ import * as fs from "node:fs";
3
+ import * as yaml from "js-yaml";
4
+ export function safeLoadYaml(filepath, fallback) {
5
+ if (!fs.existsSync(filepath)) {
6
+ return { data: fallback, error: null };
7
+ }
8
+ let lastError = null;
9
+ for (let attempt = 0; attempt < 3; attempt += 1) {
10
+ try {
11
+ const raw = fs.readFileSync(filepath, "utf-8");
12
+ const parsed = yaml.load(raw);
13
+ if (parsed === null || parsed === undefined) {
14
+ return { data: fallback, error: null };
15
+ }
16
+ return { data: parsed, error: null };
17
+ }
18
+ catch (err) {
19
+ const message = err instanceof Error ? err.message : String(err);
20
+ const lineMatch = message.match(/line (\d+)/i);
21
+ const lineInfo = lineMatch ? ` (line ${lineMatch[1]})` : "";
22
+ lastError = `Failed to parse ${basename(filepath)}${lineInfo}: ${message}`;
23
+ }
24
+ }
25
+ return { data: fallback, error: lastError };
26
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,259 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { contextPath, goalsPath, openQuestionsPath, decisionsPath, implementationStatusPath, specPath, auditPath, driftPath, handoffsDirPath, } from "./lib/files.js";
7
+ import { parseContextPack } from "./engine/contextPack.js";
8
+ import { wrapUntrustedContext } from "./lib/promptSafety.js";
9
+ import { appendDecision, normalizeDecisionActor } from "./engine/decisions.js";
10
+ import { log } from "./lib/logger.js";
11
+ import { addNote } from "./lib/notes.js";
12
+ import { AGENT_NOTE_TYPES } from "./lib/signals.js";
13
+ function safeReadFile(filepath) {
14
+ try {
15
+ return readFileSync(filepath, "utf-8");
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function latestHandoffJsonPath() {
22
+ const dir = handoffsDirPath();
23
+ let entries;
24
+ try {
25
+ entries = readdirSync(dir);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ const jsonFiles = entries.filter((f) => f.endsWith(".json"));
31
+ if (jsonFiles.length === 0)
32
+ return null;
33
+ jsonFiles.sort((a, b) => {
34
+ const aPath = path.join(dir, a);
35
+ const bPath = path.join(dir, b);
36
+ const aTime = statSync(aPath).mtimeMs;
37
+ const bTime = statSync(bPath).mtimeMs;
38
+ return bTime - aTime;
39
+ });
40
+ return path.join(dir, jsonFiles[0]);
41
+ }
42
+ async function main() {
43
+ const server = new McpServer({
44
+ name: "tack-mcp",
45
+ version: "0.1.0",
46
+ }, {});
47
+ // Intent: high-level purpose and goals, without architecture internals.
48
+ server.registerResource("intent", "tack://context/intent", {
49
+ title: "Tack Context – Intent",
50
+ description: "High-level North Star, goals, and open questions for this project.",
51
+ mimeType: "text/markdown",
52
+ }, async (uri) => {
53
+ const parts = [];
54
+ const context = safeReadFile(contextPath());
55
+ if (context) {
56
+ parts.push("# context.md", "", context.trim(), "");
57
+ }
58
+ const goals = safeReadFile(goalsPath());
59
+ if (goals) {
60
+ parts.push("# goals.md", "", goals.trim(), "");
61
+ }
62
+ const open = safeReadFile(openQuestionsPath());
63
+ if (open) {
64
+ parts.push("# open_questions.md", "", open.trim(), "");
65
+ }
66
+ const decisions = safeReadFile(decisionsPath());
67
+ if (decisions) {
68
+ parts.push("# decisions.md", "", decisions.trim(), "");
69
+ }
70
+ const text = parts.length > 0
71
+ ? parts.join("\n").trimEnd()
72
+ : "No context docs found in .tack/.";
73
+ const wrapped = wrapUntrustedContext(text, "tack://context/intent");
74
+ return {
75
+ contents: [
76
+ {
77
+ uri: uri.href,
78
+ text: wrapped,
79
+ },
80
+ ],
81
+ };
82
+ });
83
+ // Facts: implementation status and architecture spec (guardrails).
84
+ server.registerResource("facts", "tack://context/facts", {
85
+ title: "Tack Context – Facts",
86
+ description: "Binary, source-anchored implementation status and architecture spec.",
87
+ mimeType: "text/markdown",
88
+ }, async (uri) => {
89
+ const parts = [];
90
+ const impl = safeReadFile(implementationStatusPath());
91
+ if (impl) {
92
+ parts.push("# implementation_status.md", "", impl.trim(), "");
93
+ }
94
+ const spec = safeReadFile(specPath());
95
+ if (spec) {
96
+ parts.push("# spec.yaml", "", "```yaml", spec.trim(), "```", "");
97
+ }
98
+ const text = parts.length > 0
99
+ ? parts.join("\n").trimEnd()
100
+ : "No implementation_status.md or spec.yaml found in .tack/.";
101
+ const wrapped = wrapUntrustedContext(text, "tack://context/facts");
102
+ return {
103
+ contents: [
104
+ {
105
+ uri: uri.href,
106
+ text: wrapped,
107
+ },
108
+ ],
109
+ };
110
+ });
111
+ // Latest handoff JSON: canonical machine-readable summary for agents.
112
+ server.registerResource("handoff-latest", "tack://handoff/latest", {
113
+ title: "Tack Handoff – Latest",
114
+ description: "Latest handoff JSON generated by `tack handoff`.",
115
+ mimeType: "application/json",
116
+ }, async (uri) => {
117
+ const jsonPath = latestHandoffJsonPath();
118
+ if (!jsonPath) {
119
+ return {
120
+ contents: [
121
+ {
122
+ uri: uri.href,
123
+ text: JSON.stringify({ error: "No handoff JSON files found in .tack/handoffs/." }, null, 2),
124
+ },
125
+ ],
126
+ };
127
+ }
128
+ const text = safeReadFile(jsonPath) ?? "";
129
+ return {
130
+ contents: [
131
+ {
132
+ uri: uri.href,
133
+ text,
134
+ },
135
+ ],
136
+ };
137
+ });
138
+ // Recent decisions: last N entries from the parsed context pack.
139
+ server.registerResource("decisions-recent", "tack://context/decisions_recent", {
140
+ title: "Tack Decisions – Recent",
141
+ description: "Recent architecture and product decisions driving this project.",
142
+ mimeType: "text/markdown",
143
+ }, async (uri) => {
144
+ const pack = parseContextPack();
145
+ const recent = pack.decisions.slice(-10);
146
+ if (recent.length === 0) {
147
+ const wrappedEmpty = wrapUntrustedContext("No decisions recorded yet in .tack/decisions.md.", "tack://context/decisions_recent");
148
+ return {
149
+ contents: [
150
+ {
151
+ uri: uri.href,
152
+ text: wrappedEmpty,
153
+ },
154
+ ],
155
+ };
156
+ }
157
+ const lines = ["# Recent Decisions", ""];
158
+ for (const d of recent) {
159
+ lines.push(`- [${d.date}] ${d.decision} — ${d.reasoning}`);
160
+ }
161
+ const wrapped = wrapUntrustedContext(lines.join("\n"), "tack://context/decisions_recent");
162
+ return {
163
+ contents: [
164
+ {
165
+ uri: uri.href,
166
+ text: wrapped,
167
+ },
168
+ ],
169
+ };
170
+ });
171
+ // Machine state overview: audit and drift files, for engineering tasks.
172
+ server.registerResource("machine-state", "tack://context/machine_state", {
173
+ title: "Tack Machine State",
174
+ description: "Raw machine state from _audit.yaml and _drift.yaml.",
175
+ mimeType: "text/markdown",
176
+ }, async (uri) => {
177
+ const parts = [];
178
+ const audit = safeReadFile(auditPath());
179
+ if (audit) {
180
+ parts.push("# _audit.yaml", "", "```yaml", audit.trim(), "```", "");
181
+ }
182
+ const drift = safeReadFile(driftPath());
183
+ if (drift) {
184
+ parts.push("# _drift.yaml", "", "```yaml", drift.trim(), "```", "");
185
+ }
186
+ const text = parts.length > 0
187
+ ? parts.join("\n").trimEnd()
188
+ : "No _audit.yaml or _drift.yaml found in .tack/.";
189
+ const wrapped = wrapUntrustedContext(text, "tack://context/machine_state");
190
+ return {
191
+ contents: [
192
+ {
193
+ uri: uri.href,
194
+ text: wrapped,
195
+ },
196
+ ],
197
+ };
198
+ });
199
+ // Tools: write-back channels for agents.
200
+ server.registerTool("log_decision", {
201
+ description: "Record a decision with reasoning into .tack/decisions.md and the Tack event log.",
202
+ inputSchema: z.object({
203
+ decision: z.string().min(1),
204
+ reasoning: z.string().min(1),
205
+ actor: z.string().optional(),
206
+ }),
207
+ }, async (args) => {
208
+ const decision = args.decision;
209
+ const reasoning = args.reasoning;
210
+ const actor = typeof args.actor === "string" ? args.actor : undefined;
211
+ appendDecision(decision, reasoning);
212
+ log({
213
+ event: "decision",
214
+ decision,
215
+ reasoning,
216
+ actor: normalizeDecisionActor(actor),
217
+ });
218
+ return {
219
+ content: [
220
+ {
221
+ type: "text",
222
+ text: "Decision logged to .tack/decisions.md.",
223
+ },
224
+ ],
225
+ };
226
+ });
227
+ server.registerTool("log_agent_note", {
228
+ description: "Append an agent note into .tack/_notes.ndjson for future handoffs.",
229
+ inputSchema: z.object({
230
+ type: z.enum(AGENT_NOTE_TYPES),
231
+ message: z.string().min(1),
232
+ actor: z.string().optional(),
233
+ related_files: z.array(z.string()).optional(),
234
+ }),
235
+ }, async (args) => {
236
+ const actor = args.actor && args.actor.trim().length > 0 ? args.actor : "user";
237
+ const ok = addNote({
238
+ type: args.type,
239
+ message: args.message,
240
+ actor,
241
+ related_files: args.related_files,
242
+ });
243
+ const text = ok
244
+ ? "Agent note appended to .tack/_notes.ndjson."
245
+ : "Failed to append agent note to .tack/_notes.ndjson.";
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text,
251
+ },
252
+ ],
253
+ };
254
+ });
255
+ const transport = new StdioServerTransport();
256
+ await server.connect(transport);
257
+ }
258
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
259
+ main();
@@ -0,0 +1,5 @@
1
+ export declare function green(text: string): string;
2
+ export declare function red(text: string): string;
3
+ export declare function blue(text: string): string;
4
+ export declare function gray(text: string): string;
5
+ export declare function bold(text: string): string;
@@ -0,0 +1,16 @@
1
+ import pc from "picocolors";
2
+ export function green(text) {
3
+ return pc.green(text);
4
+ }
5
+ export function red(text) {
6
+ return pc.red(text);
7
+ }
8
+ export function blue(text) {
9
+ return pc.blue(text);
10
+ }
11
+ export function gray(text) {
12
+ return pc.gray(text);
13
+ }
14
+ export function bold(text) {
15
+ return pc.bold(text);
16
+ }
@@ -0,0 +1 @@
1
+ export declare function runDiffPlain(baseBranch: string | undefined): boolean;