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,529 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as yaml from "js-yaml";
4
+ import { createAudit, createEmptySpec } from "./signals.js";
5
+ import { safeLoadYaml } from "./yaml.js";
6
+ import { validateAudit, validateDriftState, validateSpec } from "./validate.js";
7
+ const LEGACY_DIRNAME = "tack";
8
+ const TACK_DIRNAME = ".tack";
9
+ export function projectRoot() {
10
+ const cwd = path.resolve(process.cwd());
11
+ let current = cwd;
12
+ while (true) {
13
+ if (path.basename(current) === TACK_DIRNAME) {
14
+ return path.dirname(current);
15
+ }
16
+ const parent = path.dirname(current);
17
+ if (parent === current)
18
+ break;
19
+ current = parent;
20
+ }
21
+ return cwd;
22
+ }
23
+ export function findProjectRoot() {
24
+ return projectRoot();
25
+ }
26
+ function getLegacyTackDir() {
27
+ return path.resolve(projectRoot(), LEGACY_DIRNAME);
28
+ }
29
+ function getTackDir() {
30
+ return path.resolve(projectRoot(), TACK_DIRNAME);
31
+ }
32
+ function emitValidationWarnings(file, warnings) {
33
+ if (warnings.length === 0)
34
+ return;
35
+ for (const warning of warnings) {
36
+ console.warn(`[tack] ${file}: ${warning}`);
37
+ }
38
+ }
39
+ function migrateLegacyDirIfNeeded() {
40
+ const legacyDir = getLegacyTackDir();
41
+ const newDir = getTackDir();
42
+ if (!fs.existsSync(newDir) && fs.existsSync(legacyDir)) {
43
+ fs.renameSync(legacyDir, newDir);
44
+ }
45
+ }
46
+ function migrateMachineFilesIfNeeded() {
47
+ const mapping = [
48
+ { oldName: "audit.yaml", newName: "_audit.yaml" },
49
+ { oldName: "drift.yaml", newName: "_drift.yaml" },
50
+ { oldName: "logs.ndjson", newName: "_logs.ndjson" },
51
+ ];
52
+ const dir = getTackDir();
53
+ if (!fs.existsSync(dir))
54
+ return;
55
+ for (const file of mapping) {
56
+ const oldPath = path.join(dir, file.oldName);
57
+ const newPath = path.join(dir, file.newName);
58
+ if (fs.existsSync(oldPath) && !fs.existsSync(newPath)) {
59
+ fs.renameSync(oldPath, newPath);
60
+ }
61
+ }
62
+ }
63
+ function assertInsideTackDir(filepath) {
64
+ const resolved = path.resolve(filepath);
65
+ const tackDir = getTackDir();
66
+ if (!resolved.startsWith(tackDir + path.sep) && resolved !== tackDir) {
67
+ throw new Error(`WRITE BLOCKED: "${resolved}" is outside /.tack/ directory. ` +
68
+ `Tack only writes to ${tackDir}. This is a bug — report it.`);
69
+ }
70
+ }
71
+ export function writeSafe(filepath, content) {
72
+ assertInsideTackDir(filepath);
73
+ const dir = path.dirname(filepath);
74
+ try {
75
+ if (!fs.existsSync(dir)) {
76
+ assertInsideTackDir(dir);
77
+ fs.mkdirSync(dir, { recursive: true });
78
+ }
79
+ fs.writeFileSync(filepath, content, "utf-8");
80
+ }
81
+ catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ if (message.includes("EACCES") || message.includes("EPERM")) {
84
+ throw new Error(`Permission denied writing ${filepath}. Check .tack permissions.`);
85
+ }
86
+ if (message.includes("ENOSPC")) {
87
+ throw new Error(`Disk full while writing ${filepath}.`);
88
+ }
89
+ throw new Error(`Failed to write ${filepath}: ${message}`);
90
+ }
91
+ }
92
+ export function appendSafe(filepath, content) {
93
+ assertInsideTackDir(filepath);
94
+ const dir = path.dirname(filepath);
95
+ try {
96
+ if (!fs.existsSync(dir)) {
97
+ assertInsideTackDir(dir);
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ }
100
+ fs.appendFileSync(filepath, content, "utf-8");
101
+ }
102
+ catch (err) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ if (message.includes("EACCES") || message.includes("EPERM")) {
105
+ throw new Error(`Permission denied writing ${filepath}. Check .tack permissions.`);
106
+ }
107
+ if (message.includes("ENOSPC")) {
108
+ throw new Error(`Disk full while writing ${filepath}.`);
109
+ }
110
+ throw new Error(`Failed to append ${filepath}: ${message}`);
111
+ }
112
+ }
113
+ export function ensureTackDir() {
114
+ migrateLegacyDirIfNeeded();
115
+ const tackDir = getTackDir();
116
+ if (!fs.existsSync(tackDir)) {
117
+ fs.mkdirSync(tackDir, { recursive: true });
118
+ }
119
+ migrateMachineFilesIfNeeded();
120
+ const handoffsDir = path.join(tackDir, "handoffs");
121
+ if (!fs.existsSync(handoffsDir)) {
122
+ fs.mkdirSync(handoffsDir, { recursive: true });
123
+ }
124
+ }
125
+ export function readFile(filepath) {
126
+ try {
127
+ return fs.readFileSync(path.resolve(projectRoot(), filepath), "utf-8");
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ export function fileExists(filepath) {
134
+ return fs.existsSync(path.resolve(projectRoot(), filepath));
135
+ }
136
+ export function readJson(filepath) {
137
+ const content = readFile(filepath);
138
+ if (!content)
139
+ return null;
140
+ try {
141
+ return JSON.parse(content);
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ export function readYaml(filepath) {
148
+ const resolved = path.resolve(projectRoot(), filepath);
149
+ const { data, error } = safeLoadYaml(resolved, null);
150
+ if (error)
151
+ return null;
152
+ return data;
153
+ }
154
+ export function listProjectFiles(dir) {
155
+ const base = projectRoot();
156
+ const root = path.resolve(base, dir ?? ".");
157
+ const pkg = readJson("package.json");
158
+ const isTackRepo = pkg?.name === "tack" || pkg?.name === "tack-cli";
159
+ const ignore = new Set([
160
+ "node_modules",
161
+ ".git",
162
+ "tack",
163
+ ".tack",
164
+ "dist",
165
+ "build",
166
+ ".next",
167
+ ".cache",
168
+ ".svelte-kit",
169
+ ".output",
170
+ ".nuxt",
171
+ ".vercel",
172
+ ".netlify",
173
+ "coverage",
174
+ "__pycache__",
175
+ "venv",
176
+ ".venv",
177
+ "env",
178
+ "site-packages",
179
+ ]);
180
+ const results = [];
181
+ const selfNoisePrefixes = [
182
+ "src/detectors/",
183
+ "src/engine/",
184
+ "src/plain/",
185
+ "src/ui/",
186
+ "tests/",
187
+ ];
188
+ function shouldSkipFile(relativePath) {
189
+ if (!isTackRepo)
190
+ return false;
191
+ const normalized = relativePath.replace(/\\/g, "/");
192
+ if (selfNoisePrefixes.some((prefix) => normalized.startsWith(prefix)))
193
+ return true;
194
+ if (normalized === "src/index.tsx" || normalized === "src/App.tsx")
195
+ return true;
196
+ if (normalized.endsWith(".md"))
197
+ return true;
198
+ return false;
199
+ }
200
+ function walk(current) {
201
+ let entries;
202
+ try {
203
+ entries = fs.readdirSync(current, { withFileTypes: true });
204
+ }
205
+ catch {
206
+ return;
207
+ }
208
+ for (const entry of entries) {
209
+ if (ignore.has(entry.name))
210
+ continue;
211
+ const full = path.join(current, entry.name);
212
+ if (entry.isDirectory()) {
213
+ walk(full);
214
+ }
215
+ else if (entry.isFile()) {
216
+ const rel = path.relative(base, full);
217
+ if (shouldSkipFile(rel))
218
+ continue;
219
+ results.push(rel);
220
+ }
221
+ }
222
+ }
223
+ walk(root);
224
+ return results;
225
+ }
226
+ export function grepFiles(files, pattern, maxResults = 50) {
227
+ const matches = [];
228
+ for (const file of files) {
229
+ if (matches.length >= maxResults)
230
+ break;
231
+ try {
232
+ const stat = fs.statSync(path.resolve(projectRoot(), file));
233
+ if (stat.size > 1024 * 1024)
234
+ continue; // Skip files larger than 1MB
235
+ }
236
+ catch {
237
+ continue;
238
+ }
239
+ const content = readFile(file);
240
+ if (!content)
241
+ continue;
242
+ const lines = content.split("\n");
243
+ for (let i = 0; i < lines.length; i += 1) {
244
+ if (matches.length >= maxResults)
245
+ break;
246
+ const line = lines[i];
247
+ if (line.length > 2000)
248
+ continue; // Skip exceptionally long lines to prevent ReDoS
249
+ if (pattern.test(line)) {
250
+ matches.push({ file, line: i + 1, content: line.trim() });
251
+ }
252
+ }
253
+ }
254
+ return matches;
255
+ }
256
+ export function specPath() {
257
+ migrateLegacyDirIfNeeded();
258
+ return path.join(getTackDir(), "spec.yaml");
259
+ }
260
+ export function readSpec() {
261
+ migrateLegacyDirIfNeeded();
262
+ return readSpecWithError().spec;
263
+ }
264
+ export function readSpecWithError() {
265
+ migrateLegacyDirIfNeeded();
266
+ const { data, error } = safeLoadYaml(specPath(), null);
267
+ if (error)
268
+ return { spec: null, error };
269
+ const validated = validateSpec(data, projectRoot());
270
+ emitValidationWarnings("spec.yaml", validated.warnings);
271
+ return { spec: validated.data, error: null };
272
+ }
273
+ export function writeSpec(spec) {
274
+ const content = yaml.dump(spec, {
275
+ lineWidth: 120,
276
+ noRefs: true,
277
+ sortKeys: false,
278
+ quotingType: '"',
279
+ });
280
+ writeSafe(specPath(), content);
281
+ }
282
+ export function specExists() {
283
+ migrateLegacyDirIfNeeded();
284
+ return fileExists(specPath());
285
+ }
286
+ export function auditPath() {
287
+ ensureTackDir();
288
+ return path.join(getTackDir(), "_audit.yaml");
289
+ }
290
+ export function readAudit() {
291
+ migrateLegacyDirIfNeeded();
292
+ migrateMachineFilesIfNeeded();
293
+ const raw = readYaml(auditPath());
294
+ const validated = validateAudit(raw);
295
+ emitValidationWarnings("_audit.yaml", validated.warnings);
296
+ return validated.data;
297
+ }
298
+ export function writeAudit(audit) {
299
+ const content = yaml.dump(audit, {
300
+ lineWidth: 120,
301
+ noRefs: true,
302
+ sortKeys: false,
303
+ });
304
+ writeSafe(auditPath(), content);
305
+ }
306
+ export function driftPath() {
307
+ ensureTackDir();
308
+ return path.join(getTackDir(), "_drift.yaml");
309
+ }
310
+ export function readDrift() {
311
+ migrateLegacyDirIfNeeded();
312
+ migrateMachineFilesIfNeeded();
313
+ const raw = readYaml(driftPath());
314
+ const validated = validateDriftState(raw);
315
+ emitValidationWarnings("_drift.yaml", validated.warnings);
316
+ return validated.data;
317
+ }
318
+ export function writeDrift(state) {
319
+ const content = yaml.dump(state, {
320
+ lineWidth: 120,
321
+ noRefs: true,
322
+ sortKeys: false,
323
+ });
324
+ writeSafe(driftPath(), content);
325
+ }
326
+ export function logsPath() {
327
+ ensureTackDir();
328
+ return path.join(getTackDir(), "_logs.ndjson");
329
+ }
330
+ export function notesPath() {
331
+ ensureTackDir();
332
+ return path.join(getTackDir(), "_notes.ndjson");
333
+ }
334
+ export function contextPath() {
335
+ return path.join(getTackDir(), "context.md");
336
+ }
337
+ export function goalsPath() {
338
+ return path.join(getTackDir(), "goals.md");
339
+ }
340
+ export function assumptionsPath() {
341
+ return path.join(getTackDir(), "assumptions.md");
342
+ }
343
+ export function openQuestionsPath() {
344
+ return path.join(getTackDir(), "open_questions.md");
345
+ }
346
+ export function decisionsPath() {
347
+ return path.join(getTackDir(), "decisions.md");
348
+ }
349
+ export function implementationStatusPath() {
350
+ return path.join(getTackDir(), "implementation_status.md");
351
+ }
352
+ export function contextIndexPath() {
353
+ return path.join(getTackDir(), "context_index.md");
354
+ }
355
+ export function verificationPath() {
356
+ return path.join(getTackDir(), "verification.md");
357
+ }
358
+ export function handoffsDirPath() {
359
+ return path.join(getTackDir(), "handoffs");
360
+ }
361
+ export function handoffMarkdownPath(timestampId) {
362
+ return path.join(handoffsDirPath(), `${timestampId}.md`);
363
+ }
364
+ export function handoffJsonPath(timestampId) {
365
+ return path.join(handoffsDirPath(), `${timestampId}.json`);
366
+ }
367
+ function contextTemplates() {
368
+ return [
369
+ {
370
+ name: "context.md",
371
+ path: contextPath(),
372
+ content: [
373
+ "# Context",
374
+ "",
375
+ "## North Star",
376
+ "- Keep this project aligned with its declared architecture.",
377
+ "",
378
+ "## Current Focus",
379
+ "- Define immediate priorities for this project.",
380
+ "",
381
+ "## Notes",
382
+ "- Add grounded context only (no speculative narrative).",
383
+ "",
384
+ ].join("\n"),
385
+ },
386
+ {
387
+ name: "goals.md",
388
+ path: goalsPath(),
389
+ content: [
390
+ "# Goals",
391
+ "",
392
+ "## Goals",
393
+ "- ",
394
+ "",
395
+ "## Non-Goals",
396
+ "- ",
397
+ "",
398
+ ].join("\n"),
399
+ },
400
+ {
401
+ name: "assumptions.md",
402
+ path: assumptionsPath(),
403
+ content: [
404
+ "# Assumptions",
405
+ "",
406
+ "- [open] ",
407
+ "",
408
+ ].join("\n"),
409
+ },
410
+ {
411
+ name: "open_questions.md",
412
+ path: openQuestionsPath(),
413
+ content: [
414
+ "# Open Questions",
415
+ "",
416
+ "- [open] ",
417
+ "",
418
+ ].join("\n"),
419
+ },
420
+ {
421
+ name: "decisions.md",
422
+ path: decisionsPath(),
423
+ content: [
424
+ "# Decisions",
425
+ "",
426
+ "- [YYYY-MM-DD] Decision title — reason",
427
+ "",
428
+ ].join("\n"),
429
+ },
430
+ {
431
+ name: "implementation_status.md",
432
+ path: implementationStatusPath(),
433
+ content: [
434
+ "# Implementation Status",
435
+ "",
436
+ "Binary, source-anchored claims only. If you can't anchor it, mark it as `unknown` or `pending`.",
437
+ "",
438
+ "Format:",
439
+ "",
440
+ "```text",
441
+ "- log_rotation: implemented (src/lib/logger.ts, src/lib/ndjson.ts)",
442
+ "- compaction_engine: pending",
443
+ "- some_feature: unknown",
444
+ "```",
445
+ "",
446
+ "Start here:",
447
+ "- ",
448
+ "",
449
+ ].join("\n"),
450
+ },
451
+ {
452
+ name: "context_index.md",
453
+ path: contextIndexPath(),
454
+ content: [
455
+ "# Context Index",
456
+ "",
457
+ "This file maps task types to the minimal `.tack/` docs needed to complete them.",
458
+ "",
459
+ "## Suggested retrieval scopes",
460
+ "",
461
+ "- agent_handoff: context.md, goals.md, open_questions.md, decisions.md, implementation_status.md, spec.yaml, _audit.yaml, _drift.yaml",
462
+ "- architecture_guardrails: spec.yaml, decisions.md, implementation_status.md",
463
+ "- product_pitch: context.md, goals.md, decisions.md",
464
+ "",
465
+ ].join("\n"),
466
+ },
467
+ {
468
+ name: "verification.md",
469
+ path: verificationPath(),
470
+ content: [
471
+ "# Validation / Verification",
472
+ "",
473
+ "Commands or checks to run after applying changes (e.g. tests, linters, health checks).",
474
+ "Tack does not execute these; they are suggestions for humans or external tools.",
475
+ "",
476
+ "- ",
477
+ "",
478
+ ].join("\n"),
479
+ },
480
+ ];
481
+ }
482
+ export function ensureContextTemplates() {
483
+ ensureTackDir();
484
+ const templates = contextTemplates();
485
+ for (const template of templates) {
486
+ if (!fileExists(template.path)) {
487
+ writeSafe(template.path, template.content);
488
+ }
489
+ }
490
+ }
491
+ export function ensureTackIntegrity() {
492
+ ensureTackDir();
493
+ migrateMachineFilesIfNeeded();
494
+ const repaired = [];
495
+ if (!specExists()) {
496
+ return { repaired };
497
+ }
498
+ const templates = contextTemplates();
499
+ for (const template of templates) {
500
+ if (!fileExists(template.path)) {
501
+ writeSafe(template.path, template.content);
502
+ repaired.push(template.name);
503
+ }
504
+ }
505
+ if (!fileExists(driftPath())) {
506
+ writeDrift({ items: [] });
507
+ repaired.push("_drift.yaml");
508
+ }
509
+ if (!fileExists(notesPath())) {
510
+ writeSafe(notesPath(), "");
511
+ repaired.push("_notes.ndjson");
512
+ }
513
+ if (!fileExists(logsPath())) {
514
+ writeSafe(logsPath(), "");
515
+ repaired.push("_logs.ndjson");
516
+ }
517
+ if (!fileExists(auditPath())) {
518
+ writeAudit(createAudit([]));
519
+ repaired.push("_audit.yaml");
520
+ }
521
+ return { repaired };
522
+ }
523
+ export function seedSpecIfMissing() {
524
+ if (specExists())
525
+ return false;
526
+ const projectName = path.basename(projectRoot()) || "my-project";
527
+ writeSpec(createEmptySpec(projectName));
528
+ return true;
529
+ }
@@ -0,0 +1,9 @@
1
+ export declare function isGitRepo(): boolean;
2
+ export declare function hasCommits(): boolean;
3
+ export declare function getCurrentBranch(): string;
4
+ export declare function getShortRef(): string;
5
+ export declare function getLatestCommitSubject(): string;
6
+ export declare function getMergeBase(refA: string, refB?: string): string | null;
7
+ export declare function readFileAtRef(ref: string, filepath: string): string | null;
8
+ export declare function filterChangedPaths(lines: string[]): string[];
9
+ export declare function getChangedFiles(base?: string): string[];
@@ -0,0 +1,96 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import * as path from "node:path";
4
+ import { projectRoot } from "./files.js";
5
+ const GIT_TIMEOUT_MS = 10_000;
6
+ function gitExec(args) {
7
+ try {
8
+ const output = execFileSync("git", args, {
9
+ cwd: projectRoot(),
10
+ encoding: "utf-8",
11
+ timeout: GIT_TIMEOUT_MS,
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ });
14
+ return { ok: true, value: output.trim() };
15
+ }
16
+ catch {
17
+ return { ok: false, value: "" };
18
+ }
19
+ }
20
+ export function isGitRepo() {
21
+ return gitExec(["rev-parse", "--is-inside-work-tree"]).ok;
22
+ }
23
+ export function hasCommits() {
24
+ return gitExec(["rev-parse", "HEAD"]).ok;
25
+ }
26
+ export function getCurrentBranch() {
27
+ const result = gitExec(["branch", "--show-current"]);
28
+ return result.ok && result.value ? result.value : "unknown";
29
+ }
30
+ export function getShortRef() {
31
+ const result = gitExec(["rev-parse", "--short", "HEAD"]);
32
+ return result.ok && result.value ? result.value : "unknown";
33
+ }
34
+ export function getLatestCommitSubject() {
35
+ const result = gitExec(["log", "-1", "--format=%s"]);
36
+ return result.ok && result.value ? result.value : "";
37
+ }
38
+ export function getMergeBase(refA, refB = "HEAD") {
39
+ const result = gitExec(["merge-base", refB, refA]);
40
+ return result.ok && result.value ? result.value : null;
41
+ }
42
+ export function readFileAtRef(ref, filepath) {
43
+ const normalizedPath = filepath.replace(/\\/g, "/");
44
+ const result = gitExec(["show", `${ref}:${normalizedPath}`]);
45
+ return result.ok && result.value ? result.value : null;
46
+ }
47
+ function dedupeAndFilter(lines) {
48
+ const seen = new Set();
49
+ const root = projectRoot();
50
+ return lines
51
+ .map((line) => line.trim())
52
+ .filter((line) => {
53
+ if (!line)
54
+ return false;
55
+ if (line.startsWith(".tack/") || line.startsWith(".tack\\"))
56
+ return false;
57
+ if (seen.has(line))
58
+ return false;
59
+ const absolute = path.resolve(root, line);
60
+ if (existsSync(absolute)) {
61
+ try {
62
+ if (!statSync(absolute).isFile())
63
+ return false;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ seen.add(line);
70
+ return true;
71
+ });
72
+ }
73
+ export function filterChangedPaths(lines) {
74
+ return dedupeAndFilter(lines);
75
+ }
76
+ export function getChangedFiles(base = "HEAD~1") {
77
+ if (!isGitRepo())
78
+ return [];
79
+ if (!hasCommits()) {
80
+ const staged = gitExec(["diff", "--cached", "--name-only"]);
81
+ const unstaged = gitExec(["diff", "--name-only"]);
82
+ const untracked = gitExec(["ls-files", "--others", "--exclude-standard"]);
83
+ const all = [
84
+ ...(staged.ok ? staged.value.split("\n") : []),
85
+ ...(unstaged.ok ? unstaged.value.split("\n") : []),
86
+ ...(untracked.ok ? untracked.value.split("\n") : []),
87
+ ];
88
+ return dedupeAndFilter(all);
89
+ }
90
+ const diffResult = gitExec(["diff", "--name-only", base]);
91
+ if (diffResult.ok) {
92
+ return dedupeAndFilter(diffResult.value.split("\n"));
93
+ }
94
+ const fallback = gitExec(["ls-files"]);
95
+ return fallback.ok ? dedupeAndFilter(fallback.value.split("\n")) : [];
96
+ }
@@ -0,0 +1,3 @@
1
+ import type { LogEvent, LogEventInput } from "./signals.js";
2
+ export declare function log(event: LogEventInput): void;
3
+ export declare function readRecentLogs<T = LogEvent>(limit?: number): T[];
@@ -0,0 +1,21 @@
1
+ import { appendSafe, logsPath } from "./files.js";
2
+ import { rotateNdjsonFile, safeReadNdjson } from "./ndjson.js";
3
+ const LOG_MAX_BYTES = 5 * 1024 * 1024;
4
+ const LOG_KEEP_LINES = 5000;
5
+ export function log(event) {
6
+ const entry = {
7
+ ts: new Date().toISOString(),
8
+ ...event,
9
+ };
10
+ const filepath = logsPath();
11
+ try {
12
+ rotateNdjsonFile(filepath, LOG_MAX_BYTES, LOG_KEEP_LINES);
13
+ appendSafe(filepath, `${JSON.stringify(entry)}\n`);
14
+ }
15
+ catch {
16
+ return;
17
+ }
18
+ }
19
+ export function readRecentLogs(limit = 50) {
20
+ return safeReadNdjson(logsPath(), limit);
21
+ }
@@ -0,0 +1,2 @@
1
+ export declare function safeReadNdjson<T = Record<string, unknown>>(filepath: string, limit?: number): T[];
2
+ export declare function rotateNdjsonFile(filepath: string, maxBytes: number, keepLines: number): void;