tack-cli 0.1.0 → 0.1.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.
- package/README.md +310 -213
- package/dist/engine/contextPack.js +1 -1
- package/dist/index.js +48 -43
- package/dist/lib/cli.d.ts +4 -2
- package/dist/lib/cli.js +6 -5
- package/dist/lib/files.d.ts +3 -0
- package/dist/lib/files.js +78 -9
- package/dist/lib/logger.d.ts +12 -0
- package/dist/lib/logger.js +53 -1
- package/dist/lib/ndjson.d.ts +1 -0
- package/dist/lib/ndjson.js +70 -10
- package/dist/lib/signals.d.ts +8 -0
- package/dist/mcp.js +8 -1
- package/dist/plain/colors.d.ts +4 -0
- package/dist/plain/colors.js +12 -0
- package/dist/plain/watch.js +20 -3
- package/dist/ui/Logo.js +1 -1
- package/dist/ui/Watch.js +32 -3
- package/package.json +53 -50
package/dist/index.js
CHANGED
|
@@ -17,18 +17,19 @@ import { runWatchPlain } from "./plain/watch.js";
|
|
|
17
17
|
import { log, readRecentLogs } from "./lib/logger.js";
|
|
18
18
|
import { appendDecision, normalizeDecisionActor, readDecisionsMarkdown } from "./engine/decisions.js";
|
|
19
19
|
import { ensureTackIntegrity } from "./lib/files.js";
|
|
20
|
-
import { fileExists } from "./lib/files.js";
|
|
21
20
|
import { readSpecWithError, specExists } from "./lib/files.js";
|
|
21
|
+
import { getDefaultCommand } from "./lib/cli.js";
|
|
22
22
|
import { printNotes, addNotePlain } from "./plain/notes.js";
|
|
23
23
|
import { compactNotes } from "./lib/notes.js";
|
|
24
24
|
import { runDiffPlain } from "./plain/diff.js";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
██║
|
|
30
|
-
██║
|
|
31
|
-
|
|
25
|
+
import { formatMissingTackContextMessage, tackDirExists } from "./lib/files.js";
|
|
26
|
+
const ASCII_LOGO = `
|
|
27
|
+
████████╗ █████╗ ██████╗██╗ ██╗
|
|
28
|
+
╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝
|
|
29
|
+
██║ ███████║██║ █████╔╝
|
|
30
|
+
██║ ██╔══██║██║ ██╔═██╗
|
|
31
|
+
██║ ██║ ██║╚██████╗██║ ██╗
|
|
32
|
+
╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
|
|
32
33
|
`;
|
|
33
34
|
import updateNotifier from "update-notifier";
|
|
34
35
|
import { readFileSync } from "node:fs";
|
|
@@ -42,41 +43,45 @@ if (args.version || args.v) {
|
|
|
42
43
|
process.exit(0);
|
|
43
44
|
}
|
|
44
45
|
const rawCommand = args._[0];
|
|
45
|
-
const command = rawCommand ?? (
|
|
46
|
+
const command = rawCommand ?? getDefaultCommand();
|
|
46
47
|
const VALID_COMMANDS = ["init", "status", "watch", "handoff", "log", "note", "diff", "mcp", "help"];
|
|
47
48
|
function isValidCommand(value) {
|
|
48
49
|
return VALID_COMMANDS.includes(value);
|
|
49
50
|
}
|
|
50
51
|
if (command === "help" || args.help || args.h) {
|
|
51
52
|
// eslint-disable-next-line no-console
|
|
52
|
-
console.log(`
|
|
53
|
-
${ASCII_LOGO}
|
|
54
|
-
tack — Architecture drift guard
|
|
55
|
-
|
|
56
|
-
Usage:
|
|
57
|
-
npx tack init [--ink] Set up spec.yaml from detected architecture
|
|
58
|
-
npx tack status [--ink] Run a scan and show current state
|
|
59
|
-
npx tack watch [--plain] Persistent watcher with live drift alerts
|
|
60
|
-
npx tack handoff [--ink] Generate agent handoff artifacts
|
|
61
|
-
npx tack log View or append decisions
|
|
62
|
-
npx tack log events [N] Show last N log events (default 50)
|
|
63
|
-
npx tack note View/add agent notes
|
|
64
|
-
npx tack diff <base-branch> Compare architecture vs base branch (plain)
|
|
65
|
-
npx tack mcp Start MCP server (for Cursor / agent integrations)
|
|
66
|
-
npx tack help Show this help text
|
|
67
|
-
|
|
68
|
-
Output mode:
|
|
69
|
-
default: plain output for all commands except watch
|
|
70
|
-
--ink: force Ink UI for init/status/handoff
|
|
71
|
-
--plain or TACK_PLAIN=1: force plain output (including watch)
|
|
72
|
-
|
|
73
|
-
Files (all in .tack/):
|
|
74
|
-
spec.yaml Your declared architecture contract
|
|
75
|
-
_audit.yaml Latest detector sweep results
|
|
76
|
-
_drift.yaml Current unresolved drift items
|
|
77
|
-
_logs.ndjson Append-only event log
|
|
78
|
-
context.md, goals.md, assumptions.md, open_questions.md
|
|
79
|
-
handoffs/<ts>.md, handoffs/<ts>.json
|
|
53
|
+
console.log(`
|
|
54
|
+
${ASCII_LOGO}
|
|
55
|
+
tack — Architecture drift guard
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
npx tack init [--ink] Set up spec.yaml from detected architecture
|
|
59
|
+
npx tack status [--ink] Run a scan and show current state
|
|
60
|
+
npx tack watch [--plain] Persistent watcher with live drift alerts
|
|
61
|
+
npx tack handoff [--ink] Generate agent handoff artifacts
|
|
62
|
+
npx tack log View or append decisions
|
|
63
|
+
npx tack log events [N] Show last N log events (default 50)
|
|
64
|
+
npx tack note View/add agent notes
|
|
65
|
+
npx tack diff <base-branch> Compare architecture vs base branch (plain)
|
|
66
|
+
npx tack mcp Start MCP server (for Cursor / agent integrations)
|
|
67
|
+
npx tack help Show this help text
|
|
68
|
+
|
|
69
|
+
Output mode:
|
|
70
|
+
default: plain output for all commands except watch
|
|
71
|
+
--ink: force Ink UI for init/status/handoff
|
|
72
|
+
--plain or TACK_PLAIN=1: force plain output (including watch)
|
|
73
|
+
|
|
74
|
+
Files (all in .tack/):
|
|
75
|
+
spec.yaml Your declared architecture contract
|
|
76
|
+
_audit.yaml Latest detector sweep results
|
|
77
|
+
_drift.yaml Current unresolved drift items
|
|
78
|
+
_logs.ndjson Append-only event log
|
|
79
|
+
context.md, goals.md, assumptions.md, open_questions.md
|
|
80
|
+
handoffs/<ts>.md, handoffs/<ts>.json
|
|
81
|
+
|
|
82
|
+
Project root:
|
|
83
|
+
Existing Tack project: nearest ancestor directory that contains .tack/
|
|
84
|
+
New project: cd to the intended project root, then run "tack init"
|
|
80
85
|
`);
|
|
81
86
|
process.exit(0);
|
|
82
87
|
}
|
|
@@ -86,14 +91,14 @@ if (!isValidCommand(command)) {
|
|
|
86
91
|
process.exit(1);
|
|
87
92
|
}
|
|
88
93
|
const normalizedCommand = command;
|
|
94
|
+
const commandsRequiringExistingTack = new Set(["status", "watch", "handoff", "log", "note", "diff", "mcp"]);
|
|
95
|
+
if (commandsRequiringExistingTack.has(normalizedCommand) && !tackDirExists()) {
|
|
96
|
+
// eslint-disable-next-line no-console
|
|
97
|
+
console.error(formatMissingTackContextMessage(normalizedCommand));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
89
100
|
// tack mcp: start the MCP server (stdio). Run from a project root that has .tack/
|
|
90
101
|
if (normalizedCommand === "mcp") {
|
|
91
|
-
const cwdTack = path.join(process.cwd(), ".tack");
|
|
92
|
-
if (!existsSync(cwdTack)) {
|
|
93
|
-
// eslint-disable-next-line no-console
|
|
94
|
-
console.error("Run `tack mcp` from a project root that contains a .tack/ directory.");
|
|
95
|
-
process.exit(1);
|
|
96
|
-
}
|
|
97
102
|
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
98
103
|
const mcpHere = path.join(dir, "mcp.js");
|
|
99
104
|
const mcpInDist = path.join(dir, "..", "dist", "mcp.js");
|
package/dist/lib/cli.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Returns the default command when `tack` is invoked with no arguments:
|
|
3
|
+
* - "init" when .tack/ does not exist
|
|
4
|
+
* - "watch" when .tack/ exists
|
|
3
5
|
*/
|
|
4
|
-
export declare function
|
|
6
|
+
export declare function getDefaultCommand(tackExists?: () => boolean): "init" | "watch";
|
package/dist/lib/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { tackDirExists } from "./files.js";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* Returns the default command when `tack` is invoked with no arguments:
|
|
4
|
+
* - "init" when .tack/ does not exist
|
|
5
|
+
* - "watch" when .tack/ exists
|
|
3
6
|
*/
|
|
4
|
-
export function
|
|
5
|
-
|
|
6
|
-
return raw;
|
|
7
|
-
return tackDirExists ? "watch" : "init";
|
|
7
|
+
export function getDefaultCommand(tackExists = tackDirExists) {
|
|
8
|
+
return tackExists() ? "watch" : "init";
|
|
8
9
|
}
|
package/dist/lib/files.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type Spec, type Audit, type DriftState } from "./signals.js";
|
|
2
2
|
export declare function projectRoot(): string;
|
|
3
3
|
export declare function findProjectRoot(): string;
|
|
4
|
+
/** True when the .tack/ directory exists (used for default CLI behavior). */
|
|
5
|
+
export declare function tackDirExists(): boolean;
|
|
6
|
+
export declare function formatMissingTackContextMessage(command: string): string;
|
|
4
7
|
export declare function writeSafe(filepath: string, content: string): void;
|
|
5
8
|
export declare function appendSafe(filepath: string, content: string): void;
|
|
6
9
|
export declare function ensureTackDir(): void;
|
package/dist/lib/files.js
CHANGED
|
@@ -6,19 +6,77 @@ import { safeLoadYaml } from "./yaml.js";
|
|
|
6
6
|
import { validateAudit, validateDriftState, validateSpec } from "./validate.js";
|
|
7
7
|
const LEGACY_DIRNAME = "tack";
|
|
8
8
|
const TACK_DIRNAME = ".tack";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const LEGACY_TACK_MARKERS = [
|
|
10
|
+
"spec.yaml",
|
|
11
|
+
"audit.yaml",
|
|
12
|
+
"drift.yaml",
|
|
13
|
+
"logs.ndjson",
|
|
14
|
+
"context.md",
|
|
15
|
+
"goals.md",
|
|
16
|
+
"assumptions.md",
|
|
17
|
+
"open_questions.md",
|
|
18
|
+
"decisions.md",
|
|
19
|
+
"implementation_status.md",
|
|
20
|
+
"verification.md",
|
|
21
|
+
"handoffs",
|
|
22
|
+
];
|
|
23
|
+
const PROJECT_MARKERS = [
|
|
24
|
+
".git",
|
|
25
|
+
"package.json",
|
|
26
|
+
"README.md",
|
|
27
|
+
"src",
|
|
28
|
+
"node_modules",
|
|
29
|
+
"backlog",
|
|
30
|
+
"dist",
|
|
31
|
+
];
|
|
32
|
+
function looksLikeLegacyTackDir(dir) {
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const entries = new Set(fs.readdirSync(dir));
|
|
38
|
+
const hasLegacyMarkers = LEGACY_TACK_MARKERS.some((name) => entries.has(name));
|
|
39
|
+
if (!hasLegacyMarkers) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const hasProjectMarkers = PROJECT_MARKERS.some((name) => entries.has(name));
|
|
43
|
+
return !hasProjectMarkers;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function findNearestProjectRootWithContext(start = process.cwd()) {
|
|
50
|
+
let current = path.resolve(start);
|
|
51
|
+
if (path.basename(current) === TACK_DIRNAME) {
|
|
52
|
+
current = path.dirname(current);
|
|
53
|
+
}
|
|
54
|
+
else if (path.basename(current) === LEGACY_DIRNAME && looksLikeLegacyTackDir(current)) {
|
|
55
|
+
current = path.dirname(current);
|
|
56
|
+
}
|
|
12
57
|
while (true) {
|
|
13
|
-
|
|
14
|
-
|
|
58
|
+
const tackDir = path.join(current, TACK_DIRNAME);
|
|
59
|
+
try {
|
|
60
|
+
if (fs.existsSync(tackDir) && fs.statSync(tackDir).isDirectory()) {
|
|
61
|
+
return current;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Ignore stat failures and keep walking upward.
|
|
66
|
+
}
|
|
67
|
+
const legacyDir = path.join(current, LEGACY_DIRNAME);
|
|
68
|
+
if (looksLikeLegacyTackDir(legacyDir)) {
|
|
69
|
+
return current;
|
|
15
70
|
}
|
|
16
71
|
const parent = path.dirname(current);
|
|
17
|
-
if (parent === current)
|
|
18
|
-
|
|
72
|
+
if (parent === current) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
19
75
|
current = parent;
|
|
20
76
|
}
|
|
21
|
-
|
|
77
|
+
}
|
|
78
|
+
export function projectRoot() {
|
|
79
|
+
return findNearestProjectRootWithContext() ?? path.resolve(process.cwd());
|
|
22
80
|
}
|
|
23
81
|
export function findProjectRoot() {
|
|
24
82
|
return projectRoot();
|
|
@@ -29,6 +87,10 @@ function getLegacyTackDir() {
|
|
|
29
87
|
function getTackDir() {
|
|
30
88
|
return path.resolve(projectRoot(), TACK_DIRNAME);
|
|
31
89
|
}
|
|
90
|
+
/** True when the .tack/ directory exists (used for default CLI behavior). */
|
|
91
|
+
export function tackDirExists() {
|
|
92
|
+
return findNearestProjectRootWithContext() !== null;
|
|
93
|
+
}
|
|
32
94
|
function emitValidationWarnings(file, warnings) {
|
|
33
95
|
if (warnings.length === 0)
|
|
34
96
|
return;
|
|
@@ -39,10 +101,17 @@ function emitValidationWarnings(file, warnings) {
|
|
|
39
101
|
function migrateLegacyDirIfNeeded() {
|
|
40
102
|
const legacyDir = getLegacyTackDir();
|
|
41
103
|
const newDir = getTackDir();
|
|
42
|
-
if (!fs.existsSync(newDir) &&
|
|
104
|
+
if (!fs.existsSync(newDir) && looksLikeLegacyTackDir(legacyDir)) {
|
|
43
105
|
fs.renameSync(legacyDir, newDir);
|
|
44
106
|
}
|
|
45
107
|
}
|
|
108
|
+
export function formatMissingTackContextMessage(command) {
|
|
109
|
+
return [
|
|
110
|
+
`No .tack/ directory was found for \`${command}\`.`,
|
|
111
|
+
"Run Tack from your project root (the directory that contains .tack/).",
|
|
112
|
+
"If this is a new project, cd to the intended root and run `tack init` first.",
|
|
113
|
+
].join(" ");
|
|
114
|
+
}
|
|
46
115
|
function migrateMachineFilesIfNeeded() {
|
|
47
116
|
const mapping = [
|
|
48
117
|
{ oldName: "audit.yaml", newName: "_audit.yaml" },
|
package/dist/lib/logger.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
import type { LogEvent, LogEventInput } from "./signals.js";
|
|
2
|
+
export type McpActivityEvent = Extract<LogEvent, {
|
|
3
|
+
event: "mcp:resource" | "mcp:tool";
|
|
4
|
+
}>;
|
|
5
|
+
export type McpActivityNotice = {
|
|
6
|
+
event: McpActivityEvent;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
2
9
|
export declare function log(event: LogEventInput): void;
|
|
3
10
|
export declare function readRecentLogs<T = LogEvent>(limit?: number): T[];
|
|
11
|
+
export declare function isMcpActivityEvent(event: LogEvent): event is McpActivityEvent;
|
|
12
|
+
export declare function readRecentMcpActivity(limit?: number): McpActivityEvent[];
|
|
13
|
+
export declare function mcpActivityEventKey(event: McpActivityEvent): string;
|
|
14
|
+
export declare function formatMcpActivityEvent(event: McpActivityEvent): string;
|
|
15
|
+
export declare function createMcpActivityMonitor(): () => McpActivityNotice[];
|
package/dist/lib/logger.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { appendSafe, logsPath } from "./files.js";
|
|
2
|
-
import { rotateNdjsonFile, safeReadNdjson } from "./ndjson.js";
|
|
2
|
+
import { createNdjsonTailReader, rotateNdjsonFile, safeReadNdjson } from "./ndjson.js";
|
|
3
3
|
const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
4
4
|
const LOG_KEEP_LINES = 5000;
|
|
5
|
+
const MCP_ACTIVITY_SUPPRESS_MS = 1500;
|
|
5
6
|
export function log(event) {
|
|
6
7
|
const entry = {
|
|
7
8
|
ts: new Date().toISOString(),
|
|
@@ -19,3 +20,54 @@ export function log(event) {
|
|
|
19
20
|
export function readRecentLogs(limit = 50) {
|
|
20
21
|
return safeReadNdjson(logsPath(), limit);
|
|
21
22
|
}
|
|
23
|
+
export function isMcpActivityEvent(event) {
|
|
24
|
+
return event.event === "mcp:resource" || event.event === "mcp:tool";
|
|
25
|
+
}
|
|
26
|
+
export function readRecentMcpActivity(limit = 50) {
|
|
27
|
+
return readRecentLogs(limit).filter(isMcpActivityEvent);
|
|
28
|
+
}
|
|
29
|
+
export function mcpActivityEventKey(event) {
|
|
30
|
+
return `${event.ts}:${event.event}:${event.event === "mcp:resource" ? event.resource : event.tool}`;
|
|
31
|
+
}
|
|
32
|
+
function formatMcpResourceName(resource) {
|
|
33
|
+
if (!resource.startsWith("tack://")) {
|
|
34
|
+
return resource;
|
|
35
|
+
}
|
|
36
|
+
return resource.replace(/^tack:\/\//, "");
|
|
37
|
+
}
|
|
38
|
+
export function formatMcpActivityEvent(event) {
|
|
39
|
+
if (event.event === "mcp:resource") {
|
|
40
|
+
return `MCP read ${formatMcpResourceName(event.resource)}`;
|
|
41
|
+
}
|
|
42
|
+
return `MCP called ${event.tool}`;
|
|
43
|
+
}
|
|
44
|
+
export function createMcpActivityMonitor() {
|
|
45
|
+
const seen = new Set(safeReadNdjson(logsPath()).filter(isMcpActivityEvent).map(mcpActivityEventKey));
|
|
46
|
+
const lastShownAt = new Map();
|
|
47
|
+
const readNewLogEvents = createNdjsonTailReader(logsPath());
|
|
48
|
+
return () => {
|
|
49
|
+
const notices = [];
|
|
50
|
+
for (const event of readNewLogEvents()) {
|
|
51
|
+
if (!isMcpActivityEvent(event))
|
|
52
|
+
continue;
|
|
53
|
+
const key = mcpActivityEventKey(event);
|
|
54
|
+
if (seen.has(key))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
const kind = event.event === "mcp:resource" ? `resource:${event.resource}` : `tool:${event.tool}`;
|
|
58
|
+
const tsMs = Date.parse(event.ts);
|
|
59
|
+
const lastMs = lastShownAt.get(kind) ?? 0;
|
|
60
|
+
if (Number.isFinite(tsMs) && tsMs - lastMs < MCP_ACTIVITY_SUPPRESS_MS) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (Number.isFinite(tsMs)) {
|
|
64
|
+
lastShownAt.set(kind, tsMs);
|
|
65
|
+
}
|
|
66
|
+
notices.push({
|
|
67
|
+
event,
|
|
68
|
+
message: formatMcpActivityEvent(event),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return notices;
|
|
72
|
+
};
|
|
73
|
+
}
|
package/dist/lib/ndjson.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export declare function safeReadNdjson<T = Record<string, unknown>>(filepath: string, limit?: number): T[];
|
|
2
|
+
export declare function createNdjsonTailReader<T = Record<string, unknown>>(filepath: string): () => T[];
|
|
2
3
|
export declare function rotateNdjsonFile(filepath: string, maxBytes: number, keepLines: number): void;
|
package/dist/lib/ndjson.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
function parseNdjsonLines(lines) {
|
|
3
|
+
const out = [];
|
|
4
|
+
for (const line of lines) {
|
|
5
|
+
if (line.trim().length === 0)
|
|
6
|
+
continue;
|
|
7
|
+
try {
|
|
8
|
+
out.push(JSON.parse(line));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
2
16
|
export function safeReadNdjson(filepath, limit) {
|
|
3
17
|
if (!fs.existsSync(filepath))
|
|
4
18
|
return [];
|
|
@@ -6,21 +20,67 @@ export function safeReadNdjson(filepath, limit) {
|
|
|
6
20
|
const raw = fs.readFileSync(filepath, "utf-8");
|
|
7
21
|
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
|
|
8
22
|
const slice = limit ? lines.slice(-limit) : lines;
|
|
9
|
-
|
|
10
|
-
for (const line of slice) {
|
|
11
|
-
try {
|
|
12
|
-
out.push(JSON.parse(line));
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return out;
|
|
23
|
+
return parseNdjsonLines(slice);
|
|
19
24
|
}
|
|
20
25
|
catch {
|
|
21
26
|
return [];
|
|
22
27
|
}
|
|
23
28
|
}
|
|
29
|
+
export function createNdjsonTailReader(filepath) {
|
|
30
|
+
let offset = 0;
|
|
31
|
+
let remainder = "";
|
|
32
|
+
try {
|
|
33
|
+
offset = fs.existsSync(filepath) ? fs.statSync(filepath).size : 0;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
offset = 0;
|
|
37
|
+
}
|
|
38
|
+
return () => {
|
|
39
|
+
if (!fs.existsSync(filepath)) {
|
|
40
|
+
offset = 0;
|
|
41
|
+
remainder = "";
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = fs.statSync(filepath);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const start = stat.size < offset ? 0 : offset;
|
|
52
|
+
const length = stat.size - start;
|
|
53
|
+
if (length <= 0) {
|
|
54
|
+
offset = stat.size;
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
let fd;
|
|
58
|
+
try {
|
|
59
|
+
fd = fs.openSync(filepath, "r");
|
|
60
|
+
const buffer = Buffer.alloc(length);
|
|
61
|
+
fs.readSync(fd, buffer, 0, length, start);
|
|
62
|
+
offset = stat.size;
|
|
63
|
+
const chunk = remainder + buffer.toString("utf-8");
|
|
64
|
+
const endsWithNewline = chunk.endsWith("\n");
|
|
65
|
+
const lines = chunk.split("\n");
|
|
66
|
+
remainder = endsWithNewline ? "" : (lines.pop() ?? "");
|
|
67
|
+
return parseNdjsonLines(lines);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
if (fd !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
fs.closeSync(fd);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Ignore close failures.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
24
84
|
export function rotateNdjsonFile(filepath, maxBytes, keepLines) {
|
|
25
85
|
if (!fs.existsSync(filepath))
|
|
26
86
|
return;
|
package/dist/lib/signals.d.ts
CHANGED
|
@@ -95,6 +95,14 @@ export type LogEvent = {
|
|
|
95
95
|
systems_detected: number;
|
|
96
96
|
drift_items: number;
|
|
97
97
|
duration_ms: number;
|
|
98
|
+
} | {
|
|
99
|
+
ts: string;
|
|
100
|
+
event: "mcp:resource";
|
|
101
|
+
resource: string;
|
|
102
|
+
} | {
|
|
103
|
+
ts: string;
|
|
104
|
+
event: "mcp:tool";
|
|
105
|
+
tool: string;
|
|
98
106
|
} | {
|
|
99
107
|
ts: string;
|
|
100
108
|
event: "drift:detected";
|
package/dist/mcp.js
CHANGED
|
@@ -42,7 +42,7 @@ function latestHandoffJsonPath() {
|
|
|
42
42
|
async function main() {
|
|
43
43
|
const server = new McpServer({
|
|
44
44
|
name: "tack-mcp",
|
|
45
|
-
version: "0.1.
|
|
45
|
+
version: "0.1.2",
|
|
46
46
|
}, {});
|
|
47
47
|
// Intent: high-level purpose and goals, without architecture internals.
|
|
48
48
|
server.registerResource("intent", "tack://context/intent", {
|
|
@@ -50,6 +50,7 @@ async function main() {
|
|
|
50
50
|
description: "High-level North Star, goals, and open questions for this project.",
|
|
51
51
|
mimeType: "text/markdown",
|
|
52
52
|
}, async (uri) => {
|
|
53
|
+
log({ event: "mcp:resource", resource: uri.href });
|
|
53
54
|
const parts = [];
|
|
54
55
|
const context = safeReadFile(contextPath());
|
|
55
56
|
if (context) {
|
|
@@ -86,6 +87,7 @@ async function main() {
|
|
|
86
87
|
description: "Binary, source-anchored implementation status and architecture spec.",
|
|
87
88
|
mimeType: "text/markdown",
|
|
88
89
|
}, async (uri) => {
|
|
90
|
+
log({ event: "mcp:resource", resource: uri.href });
|
|
89
91
|
const parts = [];
|
|
90
92
|
const impl = safeReadFile(implementationStatusPath());
|
|
91
93
|
if (impl) {
|
|
@@ -114,6 +116,7 @@ async function main() {
|
|
|
114
116
|
description: "Latest handoff JSON generated by `tack handoff`.",
|
|
115
117
|
mimeType: "application/json",
|
|
116
118
|
}, async (uri) => {
|
|
119
|
+
log({ event: "mcp:resource", resource: uri.href });
|
|
117
120
|
const jsonPath = latestHandoffJsonPath();
|
|
118
121
|
if (!jsonPath) {
|
|
119
122
|
return {
|
|
@@ -141,6 +144,7 @@ async function main() {
|
|
|
141
144
|
description: "Recent architecture and product decisions driving this project.",
|
|
142
145
|
mimeType: "text/markdown",
|
|
143
146
|
}, async (uri) => {
|
|
147
|
+
log({ event: "mcp:resource", resource: uri.href });
|
|
144
148
|
const pack = parseContextPack();
|
|
145
149
|
const recent = pack.decisions.slice(-10);
|
|
146
150
|
if (recent.length === 0) {
|
|
@@ -174,6 +178,7 @@ async function main() {
|
|
|
174
178
|
description: "Raw machine state from _audit.yaml and _drift.yaml.",
|
|
175
179
|
mimeType: "text/markdown",
|
|
176
180
|
}, async (uri) => {
|
|
181
|
+
log({ event: "mcp:resource", resource: uri.href });
|
|
177
182
|
const parts = [];
|
|
178
183
|
const audit = safeReadFile(auditPath());
|
|
179
184
|
if (audit) {
|
|
@@ -205,6 +210,7 @@ async function main() {
|
|
|
205
210
|
actor: z.string().optional(),
|
|
206
211
|
}),
|
|
207
212
|
}, async (args) => {
|
|
213
|
+
log({ event: "mcp:tool", tool: "log_decision" });
|
|
208
214
|
const decision = args.decision;
|
|
209
215
|
const reasoning = args.reasoning;
|
|
210
216
|
const actor = typeof args.actor === "string" ? args.actor : undefined;
|
|
@@ -233,6 +239,7 @@ async function main() {
|
|
|
233
239
|
related_files: z.array(z.string()).optional(),
|
|
234
240
|
}),
|
|
235
241
|
}, async (args) => {
|
|
242
|
+
log({ event: "mcp:tool", tool: "log_agent_note" });
|
|
236
243
|
const actor = args.actor && args.actor.trim().length > 0 ? args.actor : "user";
|
|
237
244
|
const ok = addNote({
|
|
238
245
|
type: args.type,
|
package/dist/plain/colors.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export declare function green(text: string): string;
|
|
2
2
|
export declare function red(text: string): string;
|
|
3
3
|
export declare function blue(text: string): string;
|
|
4
|
+
export declare function cyan(text: string): string;
|
|
5
|
+
export declare function yellow(text: string): string;
|
|
4
6
|
export declare function gray(text: string): string;
|
|
5
7
|
export declare function bold(text: string): string;
|
|
8
|
+
export declare function checkBadge(): string;
|
|
9
|
+
export declare function mcpBadge(): string;
|
package/dist/plain/colors.js
CHANGED
|
@@ -8,9 +8,21 @@ export function red(text) {
|
|
|
8
8
|
export function blue(text) {
|
|
9
9
|
return pc.blue(text);
|
|
10
10
|
}
|
|
11
|
+
export function cyan(text) {
|
|
12
|
+
return pc.cyan(text);
|
|
13
|
+
}
|
|
14
|
+
export function yellow(text) {
|
|
15
|
+
return pc.yellow(text);
|
|
16
|
+
}
|
|
11
17
|
export function gray(text) {
|
|
12
18
|
return pc.gray(text);
|
|
13
19
|
}
|
|
14
20
|
export function bold(text) {
|
|
15
21
|
return pc.bold(text);
|
|
16
22
|
}
|
|
23
|
+
export function checkBadge() {
|
|
24
|
+
return pc.bgYellow(pc.black(" CHECK "));
|
|
25
|
+
}
|
|
26
|
+
export function mcpBadge() {
|
|
27
|
+
return pc.bgCyan(pc.black(" MCP "));
|
|
28
|
+
}
|
package/dist/plain/watch.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import chokidar from "chokidar";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { logsPath } from "../lib/files.js";
|
|
3
4
|
import { runStatusScan } from "../engine/status.js";
|
|
4
|
-
import {
|
|
5
|
+
import { createMcpActivityMonitor } from "../lib/logger.js";
|
|
6
|
+
import { blue, checkBadge, gray, green, mcpBadge, red, yellow } from "./colors.js";
|
|
5
7
|
const IGNORE_PATTERNS = [
|
|
6
8
|
"**/node_modules/**",
|
|
7
9
|
"**/.git/**",
|
|
@@ -25,7 +27,7 @@ function printSnapshot(reason) {
|
|
|
25
27
|
}
|
|
26
28
|
const ts = new Date().toISOString();
|
|
27
29
|
const healthy = result.status.health === "aligned";
|
|
28
|
-
console.log(`${blue(`[${ts}]`)} ${reason} :: health=${healthy ? green("aligned") : red("drift")} drift=${result.status.driftCount > 0 ? red(String(result.status.driftCount)) : green("0")}`);
|
|
30
|
+
console.log(`${checkBadge()} ${blue(`[${ts}]`)} ${yellow(reason)} :: health=${healthy ? green("aligned") : red("drift")} drift=${result.status.driftCount > 0 ? red(String(result.status.driftCount)) : green("0")}`);
|
|
29
31
|
for (const item of result.status.driftItems.slice(0, 5)) {
|
|
30
32
|
console.log(` - ${red(item.system)}: ${item.message}`);
|
|
31
33
|
}
|
|
@@ -38,7 +40,7 @@ export async function runWatchPlain() {
|
|
|
38
40
|
const ok = printSnapshot("initial");
|
|
39
41
|
if (!ok)
|
|
40
42
|
return;
|
|
41
|
-
console.log(`${gray("Watching for changes (plain mode). Press Ctrl+C to stop.")}`);
|
|
43
|
+
console.log(`${gray("Watching for changes and MCP activity (plain mode). Press Ctrl+C to stop.")}`);
|
|
42
44
|
const watcher = chokidar.watch(".", {
|
|
43
45
|
ignored: IGNORE_PATTERNS,
|
|
44
46
|
persistent: true,
|
|
@@ -48,6 +50,15 @@ export async function runWatchPlain() {
|
|
|
48
50
|
pollInterval: 50,
|
|
49
51
|
},
|
|
50
52
|
});
|
|
53
|
+
const logsWatcher = chokidar.watch(logsPath(), {
|
|
54
|
+
persistent: true,
|
|
55
|
+
ignoreInitial: true,
|
|
56
|
+
awaitWriteFinish: {
|
|
57
|
+
stabilityThreshold: 100,
|
|
58
|
+
pollInterval: 50,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const readNewMcpActivity = createMcpActivityMonitor();
|
|
51
62
|
let debounceTimer = null;
|
|
52
63
|
const shutdown = async () => {
|
|
53
64
|
if (debounceTimer) {
|
|
@@ -55,8 +66,14 @@ export async function runWatchPlain() {
|
|
|
55
66
|
debounceTimer = null;
|
|
56
67
|
}
|
|
57
68
|
await watcher.close();
|
|
69
|
+
await logsWatcher.close();
|
|
58
70
|
console.log(gray("Stopped watch mode."));
|
|
59
71
|
};
|
|
72
|
+
logsWatcher.on("change", () => {
|
|
73
|
+
for (const notice of readNewMcpActivity()) {
|
|
74
|
+
console.log(`${mcpBadge()} ${blue(`[${notice.event.ts}]`)} ${gray(notice.message)}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
60
77
|
watcher.on("all", (event, filepath) => {
|
|
61
78
|
if (filepath.includes(`${path.sep}.tack${path.sep}`))
|
|
62
79
|
return;
|
package/dist/ui/Logo.js
CHANGED
|
@@ -9,5 +9,5 @@ const LOGO = `
|
|
|
9
9
|
╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
|
|
10
10
|
`;
|
|
11
11
|
export function Logo() {
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: "cyan", children: LOGO }), _jsx(Text, { dimColor: true, children: " Architecture drift guard \u2022 v0.1.
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: "cyan", children: LOGO }), _jsx(Text, { dimColor: true, children: " Architecture drift guard \u2022 v0.1.2" })] }));
|
|
13
13
|
}
|