@askthew/mcp-plugin 0.2.8 → 0.4.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 +65 -16
- package/dist/auth-pending.test.d.ts +1 -0
- package/dist/auth-pending.test.js +56 -0
- package/dist/cli-actions.test.d.ts +1 -0
- package/dist/cli-actions.test.js +71 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +412 -18
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +274 -0
- package/dist/free-tier-policy.test.d.ts +1 -0
- package/dist/free-tier-policy.test.js +57 -0
- package/dist/index.d.ts +59 -13
- package/dist/index.js +1736 -103
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +952 -0
- package/dist/install.d.ts +56 -1
- package/dist/install.js +171 -26
- package/dist/install.test.d.ts +1 -0
- package/dist/install.test.js +297 -0
- package/dist/lib/auth-magic-link.d.ts +22 -0
- package/dist/lib/auth-magic-link.js +43 -0
- package/dist/lib/auth-pending.d.ts +23 -0
- package/dist/lib/auth-pending.js +36 -0
- package/dist/lib/cli-actions.d.ts +28 -0
- package/dist/lib/cli-actions.js +104 -0
- package/dist/lib/free-install-registration.d.ts +27 -0
- package/dist/lib/free-install-registration.js +52 -0
- package/dist/lib/free-tier-policy.d.ts +23 -0
- package/dist/lib/free-tier-policy.js +68 -0
- package/dist/lib/local-identity.d.ts +44 -0
- package/dist/lib/local-identity.js +81 -0
- package/dist/lib/local-store.d.ts +130 -0
- package/dist/lib/local-store.js +595 -0
- package/dist/lib/loopback-auth.d.ts +8 -0
- package/dist/lib/loopback-auth.js +30 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.js +50 -0
- package/dist/lib/telemetry.d.ts +25 -0
- package/dist/lib/telemetry.js +159 -0
- package/dist/lib/timeline-insights.d.ts +23 -0
- package/dist/lib/timeline-insights.js +115 -0
- package/dist/lib/tip-engine.d.ts +18 -0
- package/dist/lib/tip-engine.js +237 -0
- package/dist/lib/upgrade-nudge.d.ts +19 -0
- package/dist/lib/upgrade-nudge.js +37 -0
- package/dist/lib/upgrade-sync.d.ts +38 -0
- package/dist/lib/upgrade-sync.js +60 -0
- package/dist/local-identity.test.d.ts +1 -0
- package/dist/local-identity.test.js +29 -0
- package/dist/local-store.test.d.ts +1 -0
- package/dist/local-store.test.js +71 -0
- package/dist/scope.d.ts +1 -2
- package/dist/scope.js +56 -8
- package/dist/scope.test.d.ts +1 -0
- package/dist/scope.test.js +49 -0
- package/dist/timeline-insights.test.d.ts +1 -0
- package/dist/timeline-insights.test.js +85 -0
- package/dist/tip-engine.test.d.ts +1 -0
- package/dist/tip-engine.test.js +51 -0
- package/package.json +7 -10
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function syncDryRun(store) {
|
|
2
|
+
const pendingSignals = store.listSignals({ limit: 100000 }).filter((signal) => !store.getMeta(`signal_uploaded:${signal.id}`));
|
|
3
|
+
const pendingDecisions = store.listDecisions({ limit: 100000, pendingUploadOnly: true });
|
|
4
|
+
const alreadySignals = store.listSignals({ limit: 100000 }).length - pendingSignals.length;
|
|
5
|
+
const alreadyDecisions = store.listDecisions({ limit: 100000 }).filter((decision) => decision.uploadedAt).length;
|
|
6
|
+
return {
|
|
7
|
+
pending: { signals: pendingSignals.length, decisions: pendingDecisions.length },
|
|
8
|
+
alreadyUploaded: { signals: alreadySignals, decisions: alreadyDecisions },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export async function uploadLocalStore(input) {
|
|
12
|
+
if (input.dryRun) {
|
|
13
|
+
return { ok: true, dryRun: true, ...syncDryRun(input.store) };
|
|
14
|
+
}
|
|
15
|
+
const fetcher = input.fetchImpl ?? fetch;
|
|
16
|
+
const apiUrl = (input.apiUrl ?? input.credentials.apiUrl ?? process.env.ASKTHEW_API_URL ?? "https://app.askthew.com").replace(/\/$/, "");
|
|
17
|
+
const token = input.syncToken ?? input.credentials.cliToken;
|
|
18
|
+
const signalRows = input.store
|
|
19
|
+
.listSignals({ limit: 100000 })
|
|
20
|
+
.filter((signal) => !input.store.getMeta(`signal_uploaded:${signal.id}`));
|
|
21
|
+
const decisionRows = input.store.listDecisions({ limit: 100000, pendingUploadOnly: true }).reverse();
|
|
22
|
+
let uploadedSignals = 0;
|
|
23
|
+
let uploadedDecisions = 0;
|
|
24
|
+
for (const chunk of chunks(signalRows, 100)) {
|
|
25
|
+
await postChunk(apiUrl, token, fetcher, "signals", chunk);
|
|
26
|
+
for (const signal of chunk) {
|
|
27
|
+
input.store.setMeta(`signal_uploaded:${signal.id}`, new Date().toISOString());
|
|
28
|
+
}
|
|
29
|
+
uploadedSignals += chunk.length;
|
|
30
|
+
}
|
|
31
|
+
for (const chunk of chunks(decisionRows, 100)) {
|
|
32
|
+
await postChunk(apiUrl, token, fetcher, "decisions", chunk);
|
|
33
|
+
for (const decision of chunk) {
|
|
34
|
+
input.store.updateDecision(decision.id, { uploadedAt: new Date().toISOString() });
|
|
35
|
+
}
|
|
36
|
+
uploadedDecisions += chunk.length;
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, uploaded: { signals: uploadedSignals, decisions: uploadedDecisions } };
|
|
39
|
+
}
|
|
40
|
+
async function postChunk(apiUrl, token, fetcher, kind, items) {
|
|
41
|
+
const response = await fetcher(`${apiUrl}/api/cli/v1/upgrade/sync`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
Authorization: `Bearer ${token}`,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ uploadVersion: 1, kind, items }),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const body = await response.json().catch(() => null);
|
|
51
|
+
throw new Error(body?.error ? String(body.error) : `Upload failed for ${kind}.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function chunks(items, size) {
|
|
55
|
+
const result = [];
|
|
56
|
+
for (let index = 0; index < items.length; index += size) {
|
|
57
|
+
result.push(items.slice(index, index + size));
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { ensureLocalIdentity, signLocalIdentityPayload } from "./lib/local-identity.js";
|
|
8
|
+
import { identityPath } from "./lib/paths.js";
|
|
9
|
+
test("local identity uses install id as primary identity and signs summary payloads", () => {
|
|
10
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-local-identity-"));
|
|
11
|
+
try {
|
|
12
|
+
const env = { ASKTHEW_DATA_DIR: dataDir };
|
|
13
|
+
const identity = ensureLocalIdentity({
|
|
14
|
+
emailClaim: "Founder@Example.com",
|
|
15
|
+
apiUrl: "https://app.askthew.com",
|
|
16
|
+
env,
|
|
17
|
+
});
|
|
18
|
+
const body = JSON.stringify({ schemaVersion: 1, activity: { signalCount: 1 } });
|
|
19
|
+
const signed = signLocalIdentityPayload({ identity, body, timestamp: "2026-05-07T12:00:00.000Z" });
|
|
20
|
+
assert.match(identity.installId, /^[0-9a-f-]{36}$/);
|
|
21
|
+
assert.equal(identity.emailClaim, "founder@example.com");
|
|
22
|
+
assert.equal(fs.existsSync(identityPath(env)), true);
|
|
23
|
+
assert.equal(crypto.verify(null, Buffer.from(`${signed.timestamp}.${body}`), identity.publicKey, Buffer.from(signed.signature, "base64url")), true);
|
|
24
|
+
assert.equal(crypto.verify(null, Buffer.from(`${signed.timestamp}.${JSON.stringify({ tampered: true })}`), identity.publicKey, Buffer.from(signed.signature, "base64url")), false);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { LocalStore } from "./lib/local-store.js";
|
|
7
|
+
test("local store migrates, captures signals, decisions, and FIFO telemetry", () => {
|
|
8
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-store-"));
|
|
9
|
+
const store = LocalStore.open({ path: path.join(dir, "store.sqlite") });
|
|
10
|
+
const signal = store.insertSignal({
|
|
11
|
+
sessionId: "s1",
|
|
12
|
+
sequence: 1,
|
|
13
|
+
kind: "session_checkpoint",
|
|
14
|
+
summary: "Checkpoint",
|
|
15
|
+
filesTouched: ["src/app.ts"],
|
|
16
|
+
commandsRun: ["npm test"],
|
|
17
|
+
});
|
|
18
|
+
const duplicate = store.insertSignal({
|
|
19
|
+
sessionId: "s1",
|
|
20
|
+
sequence: 1,
|
|
21
|
+
kind: "session_checkpoint",
|
|
22
|
+
summary: "Checkpoint duplicate",
|
|
23
|
+
});
|
|
24
|
+
const decision = store.createDecision({
|
|
25
|
+
rawContent: "Use local SQLite for free-tier capture.",
|
|
26
|
+
sessionId: "s1",
|
|
27
|
+
sourceSignalIds: [signal.id],
|
|
28
|
+
});
|
|
29
|
+
const firstOutbox = store.enqueueTelemetry({ n: 1 });
|
|
30
|
+
const secondOutbox = store.enqueueTelemetry({ n: 2 });
|
|
31
|
+
assert.equal(signal.id, duplicate.id);
|
|
32
|
+
assert.equal(store.listSignals({ sessionId: "s1" }).length, 1);
|
|
33
|
+
assert.equal(store.getDecision(decision.id)?.headline, "Use local SQLite for free-tier capture.");
|
|
34
|
+
assert.deepEqual(store.listTelemetryOutbox().map((row) => row.id), [firstOutbox, secondOutbox]);
|
|
35
|
+
store.close();
|
|
36
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
test("local store scopes trail rows, links decisions, and tracks lifecycle timestamps", () => {
|
|
39
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-store-scope-"));
|
|
40
|
+
const store = LocalStore.open({ path: path.join(dir, "store.sqlite") });
|
|
41
|
+
const scoped = store.insertSignal({
|
|
42
|
+
sessionId: "s1",
|
|
43
|
+
sequence: 1,
|
|
44
|
+
kind: "direction_change",
|
|
45
|
+
summary: "Let's go with local search because it is fast.",
|
|
46
|
+
scopeKey: "repo-a",
|
|
47
|
+
});
|
|
48
|
+
store.insertSignal({
|
|
49
|
+
sessionId: "s2",
|
|
50
|
+
sequence: 1,
|
|
51
|
+
kind: "direction_change",
|
|
52
|
+
summary: "Use a different decision in another repo.",
|
|
53
|
+
scopeKey: "repo-b",
|
|
54
|
+
});
|
|
55
|
+
const decision = store.createDecision({
|
|
56
|
+
rawContent: "Adopt local search.",
|
|
57
|
+
sessionId: "s1",
|
|
58
|
+
sourceSignalIds: [scoped.id],
|
|
59
|
+
scopeKey: "repo-a",
|
|
60
|
+
});
|
|
61
|
+
const committed = store.updateDecision(decision.id, { status: "committed" });
|
|
62
|
+
assert.equal(store.listSignals({ scopeKey: "repo-a" }).length, 1);
|
|
63
|
+
assert.equal(store.listSignals({ scopeKey: "repo-b" }).length, 1);
|
|
64
|
+
assert.equal(store.listDecisions({ scopeKey: "repo-a" }).length, 1);
|
|
65
|
+
assert.equal(store.getDecisionForSignal(scoped.id)?.id, decision.id);
|
|
66
|
+
assert.equal(Boolean(decision.proposedAt), true);
|
|
67
|
+
assert.equal(Boolean(committed?.committedAt), true);
|
|
68
|
+
assert.equal(store.mostRecentSessionId({ scopeKey: "repo-a" }), "s1");
|
|
69
|
+
store.close();
|
|
70
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
});
|
package/dist/scope.d.ts
CHANGED
|
@@ -4,5 +4,4 @@ export interface PluginScope {
|
|
|
4
4
|
appPath?: string;
|
|
5
5
|
serviceName?: string;
|
|
6
6
|
}
|
|
7
|
-
export declare function resolvePluginScope(startDirectory?: string): PluginScope;
|
|
8
|
-
export declare function resolveFunctionalAreaFromToml(startDirectory?: string): string;
|
|
7
|
+
export declare function resolvePluginScope(startDirectory?: string, env?: NodeJS.ProcessEnv): PluginScope;
|
package/dist/scope.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
const REPO_MARKERS = [".git", "package.json", "pyproject.toml", "go.mod", "Cargo.toml"];
|
|
4
|
+
const PROJECT_ROOT_ENV_KEYS = [
|
|
5
|
+
"ASKTHEW_REPO_ROOT",
|
|
6
|
+
"ASKTHEW_PROJECT_ROOT",
|
|
7
|
+
"CODEX_WORKSPACE",
|
|
8
|
+
"CODEX_PROJECT_DIR",
|
|
9
|
+
"CLAUDE_PROJECT_DIR",
|
|
10
|
+
"MCP_PROJECT_ROOT",
|
|
11
|
+
"PROJECT_ROOT",
|
|
12
|
+
"WORKSPACE_FOLDER",
|
|
13
|
+
"GITHUB_WORKSPACE",
|
|
14
|
+
"INIT_CWD",
|
|
15
|
+
"PWD",
|
|
16
|
+
];
|
|
4
17
|
function parseAskTheWToml(filePath) {
|
|
5
18
|
if (!fs.existsSync(filePath)) {
|
|
6
19
|
return {};
|
|
@@ -15,7 +28,6 @@ function parseAskTheWToml(filePath) {
|
|
|
15
28
|
repoRoot: readValue("repo_root"),
|
|
16
29
|
appPath: readValue("app_path"),
|
|
17
30
|
serviceName: readValue("service_name"),
|
|
18
|
-
functionalArea: readValue("functional_area"),
|
|
19
31
|
};
|
|
20
32
|
}
|
|
21
33
|
function findRepoRoot(startDirectory) {
|
|
@@ -36,10 +48,51 @@ function findRepoRoot(startDirectory) {
|
|
|
36
48
|
}
|
|
37
49
|
return highestMarkerDirectory;
|
|
38
50
|
}
|
|
39
|
-
|
|
51
|
+
function cleanEnv(value) {
|
|
52
|
+
return String(value ?? "").trim().replace(/^['"]/, "").replace(/['"]$/, "");
|
|
53
|
+
}
|
|
54
|
+
function explicitEnvScope(env) {
|
|
55
|
+
const repoName = cleanEnv(env.ASKTHEW_REPO_NAME);
|
|
56
|
+
const repoRoot = cleanEnv(env.ASKTHEW_REPO_ROOT);
|
|
57
|
+
const appPath = cleanEnv(env.ASKTHEW_APP_PATH);
|
|
58
|
+
const serviceName = cleanEnv(env.ASKTHEW_SERVICE_NAME);
|
|
59
|
+
if (!repoName && !repoRoot) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const resolvedRoot = repoRoot ? path.resolve(repoRoot) : undefined;
|
|
63
|
+
return {
|
|
64
|
+
repoName: repoName || path.basename(resolvedRoot ?? process.cwd()),
|
|
65
|
+
...(resolvedRoot ? { repoRoot: resolvedRoot } : {}),
|
|
66
|
+
...(appPath ? { appPath } : {}),
|
|
67
|
+
...(serviceName ? { serviceName } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function envRepoRoot(env) {
|
|
71
|
+
for (const key of PROJECT_ROOT_ENV_KEYS) {
|
|
72
|
+
const value = cleanEnv(env[key]);
|
|
73
|
+
if (!value)
|
|
74
|
+
continue;
|
|
75
|
+
const candidate = findRepoRoot(value);
|
|
76
|
+
if (candidate)
|
|
77
|
+
return candidate;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
export function resolvePluginScope(startDirectory = process.cwd(), env = process.env) {
|
|
40
82
|
const cwd = path.resolve(startDirectory);
|
|
83
|
+
const envScope = explicitEnvScope(env);
|
|
84
|
+
if (envScope) {
|
|
85
|
+
return envScope;
|
|
86
|
+
}
|
|
41
87
|
const parsedConfig = parseAskTheWToml(path.join(cwd, ".asktheworld.toml"));
|
|
42
|
-
const
|
|
88
|
+
const discoveredRoot = findRepoRoot(cwd);
|
|
89
|
+
const fallbackEnvRoot = envRepoRoot(env);
|
|
90
|
+
const repoRoot = parsedConfig.repoRoot ||
|
|
91
|
+
(fallbackEnvRoot && (!discoveredRoot || path.basename(discoveredRoot) === "task")
|
|
92
|
+
? fallbackEnvRoot
|
|
93
|
+
: discoveredRoot) ||
|
|
94
|
+
fallbackEnvRoot ||
|
|
95
|
+
cwd;
|
|
43
96
|
const repoName = parsedConfig.repoName || path.basename(repoRoot);
|
|
44
97
|
const relativePath = path.relative(repoRoot, cwd);
|
|
45
98
|
const normalizedRelativePath = relativePath && relativePath !== "." && !relativePath.startsWith("..")
|
|
@@ -53,8 +106,3 @@ export function resolvePluginScope(startDirectory = process.cwd()) {
|
|
|
53
106
|
...(parsedConfig.serviceName ? { serviceName: parsedConfig.serviceName } : {}),
|
|
54
107
|
};
|
|
55
108
|
}
|
|
56
|
-
export function resolveFunctionalAreaFromToml(startDirectory = process.cwd()) {
|
|
57
|
-
const cwd = path.resolve(startDirectory);
|
|
58
|
-
const parsedConfig = parseAskTheWToml(path.join(cwd, ".asktheworld.toml"));
|
|
59
|
-
return parsedConfig.functionalArea || "";
|
|
60
|
-
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { resolvePluginScope } from "./scope.js";
|
|
7
|
+
test("resolvePluginScope derives repo and app path from cwd", () => {
|
|
8
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-scope-"));
|
|
9
|
+
const repoRoot = path.join(tempRoot, "repo");
|
|
10
|
+
const appRoot = path.join(repoRoot, "apps", "landing");
|
|
11
|
+
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
|
|
12
|
+
fs.mkdirSync(appRoot, { recursive: true });
|
|
13
|
+
const scope = resolvePluginScope(appRoot);
|
|
14
|
+
assert.equal(scope.repoName, "repo");
|
|
15
|
+
assert.equal(scope.repoRoot, repoRoot);
|
|
16
|
+
assert.equal(scope.appPath, "apps/landing");
|
|
17
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
test("resolvePluginScope respects explicit .asktheworld.toml values", () => {
|
|
20
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-scope-config-"));
|
|
21
|
+
fs.mkdirSync(tempRoot, { recursive: true });
|
|
22
|
+
fs.writeFileSync(path.join(tempRoot, ".asktheworld.toml"), [
|
|
23
|
+
'repo_name = "main-platform"',
|
|
24
|
+
'app_path = "apps/decisions"',
|
|
25
|
+
'service_name = "decisions"',
|
|
26
|
+
].join("\n"), "utf8");
|
|
27
|
+
const scope = resolvePluginScope(tempRoot);
|
|
28
|
+
assert.equal(scope.repoName, "main-platform");
|
|
29
|
+
assert.equal(scope.appPath, "apps/decisions");
|
|
30
|
+
assert.equal(scope.serviceName, "decisions");
|
|
31
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
test("resolvePluginScope prefers install-time repo env over npx sandbox cwd", () => {
|
|
34
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-scope-env-"));
|
|
35
|
+
const repoRoot = path.join(tempRoot, "ThesisEngine-main");
|
|
36
|
+
const sandboxRoot = path.join(tempRoot, "task");
|
|
37
|
+
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
|
|
38
|
+
fs.mkdirSync(sandboxRoot, { recursive: true });
|
|
39
|
+
fs.writeFileSync(path.join(sandboxRoot, "package.json"), "{}", "utf8");
|
|
40
|
+
const scope = resolvePluginScope(sandboxRoot, {
|
|
41
|
+
ASKTHEW_REPO_NAME: "ThesisEngine-main",
|
|
42
|
+
ASKTHEW_REPO_ROOT: repoRoot,
|
|
43
|
+
ASKTHEW_APP_PATH: "apps/app",
|
|
44
|
+
});
|
|
45
|
+
assert.equal(scope.repoName, "ThesisEngine-main");
|
|
46
|
+
assert.equal(scope.repoRoot, repoRoot);
|
|
47
|
+
assert.equal(scope.appPath, "apps/app");
|
|
48
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildTimelineInsights, buildLocalTimeline } from "./lib/timeline-insights.js";
|
|
4
|
+
function signal(id, day) {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
sessionId: "session-a",
|
|
8
|
+
sequence: id,
|
|
9
|
+
kind: "session_checkpoint",
|
|
10
|
+
summary: "checkpoint",
|
|
11
|
+
evidence: [],
|
|
12
|
+
filesTouched: [],
|
|
13
|
+
commandsRun: [],
|
|
14
|
+
metadata: {},
|
|
15
|
+
capturedAt: `${day}T10:00:00.000Z`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
test("local timeline buckets signals and decisions by day", () => {
|
|
19
|
+
const decisions = [{
|
|
20
|
+
id: "d1",
|
|
21
|
+
sessionId: "session-a",
|
|
22
|
+
headline: "Decision",
|
|
23
|
+
why: null,
|
|
24
|
+
status: "proposed",
|
|
25
|
+
alignment: null,
|
|
26
|
+
files: [],
|
|
27
|
+
sourceSignalIds: [],
|
|
28
|
+
rawContent: "Decision",
|
|
29
|
+
createdAt: "2026-05-01T11:00:00.000Z",
|
|
30
|
+
updatedAt: "2026-05-01T11:00:00.000Z",
|
|
31
|
+
uploadedAt: null,
|
|
32
|
+
}];
|
|
33
|
+
const points = buildLocalTimeline({
|
|
34
|
+
scope: "day",
|
|
35
|
+
signals: [signal(1, "2026-05-01"), signal(2, "2026-05-02")],
|
|
36
|
+
decisions,
|
|
37
|
+
});
|
|
38
|
+
assert.deepEqual(points.map((point) => [point.x, point.signalCount, point.decisionCount]), [
|
|
39
|
+
["2026-05-01", 1, 1],
|
|
40
|
+
["2026-05-02", 1, 0],
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
test("local timeline insight ids stay in timeline namespace", () => {
|
|
44
|
+
const insights = buildTimelineInsights([{ x: "2026-05-01", signalCount: 20, decisionCount: 1 }]);
|
|
45
|
+
assert.ok(insights.every((insight) => insight.id.startsWith("timeline.")));
|
|
46
|
+
});
|
|
47
|
+
test("session timeline buckets by sessionId without inventing Other", () => {
|
|
48
|
+
const decisions = [
|
|
49
|
+
{
|
|
50
|
+
id: "d1",
|
|
51
|
+
sessionId: null,
|
|
52
|
+
headline: "Decision without session",
|
|
53
|
+
why: null,
|
|
54
|
+
status: "proposed",
|
|
55
|
+
alignment: null,
|
|
56
|
+
files: [],
|
|
57
|
+
sourceSignalIds: [],
|
|
58
|
+
rawContent: "Decision without session",
|
|
59
|
+
createdAt: "2026-05-01T11:00:00.000Z",
|
|
60
|
+
updatedAt: "2026-05-01T11:00:00.000Z",
|
|
61
|
+
uploadedAt: null,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "d2",
|
|
65
|
+
sessionId: "session-a",
|
|
66
|
+
headline: "Decision with session",
|
|
67
|
+
why: null,
|
|
68
|
+
status: "proposed",
|
|
69
|
+
alignment: null,
|
|
70
|
+
files: [],
|
|
71
|
+
sourceSignalIds: [],
|
|
72
|
+
rawContent: "Decision with session",
|
|
73
|
+
createdAt: "2026-05-01T11:00:00.000Z",
|
|
74
|
+
updatedAt: "2026-05-01T11:00:00.000Z",
|
|
75
|
+
uploadedAt: null,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
const points = buildLocalTimeline({
|
|
79
|
+
scope: "session",
|
|
80
|
+
signals: [signal(1, "2026-05-01")],
|
|
81
|
+
decisions,
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(points.map((point) => point.x), ["session-a"]);
|
|
84
|
+
assert.equal(points[0]?.decisionCount, 1);
|
|
85
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { analyzeLocalPatterns } from "./lib/tip-engine.js";
|
|
4
|
+
function signal(id, kind, summary = "ok") {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
sessionId: "s1",
|
|
8
|
+
sequence: id,
|
|
9
|
+
kind,
|
|
10
|
+
summary,
|
|
11
|
+
evidence: [],
|
|
12
|
+
filesTouched: [`file${id}.ts`],
|
|
13
|
+
commandsRun: [],
|
|
14
|
+
metadata: {},
|
|
15
|
+
capturedAt: "2026-05-05T10:00:00.000Z",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
test("tip engine is deterministic and sorts top severity first", () => {
|
|
19
|
+
const signals = [
|
|
20
|
+
signal(1, "direction_change"),
|
|
21
|
+
signal(2, "direction_change"),
|
|
22
|
+
signal(3, "direction_change"),
|
|
23
|
+
signal(4, "direction_change"),
|
|
24
|
+
signal(5, "verification_result", "failed"),
|
|
25
|
+
signal(6, "verification_result", "failed"),
|
|
26
|
+
signal(7, "verification_result", "failed"),
|
|
27
|
+
signal(8, "verification_result", "passed"),
|
|
28
|
+
];
|
|
29
|
+
const decisions = [1, 2, 3].map((index) => ({
|
|
30
|
+
id: `d_${index}`,
|
|
31
|
+
sessionId: "s1",
|
|
32
|
+
headline: `Decision ${index}`,
|
|
33
|
+
why: "",
|
|
34
|
+
status: "proposed",
|
|
35
|
+
alignment: null,
|
|
36
|
+
files: [],
|
|
37
|
+
sourceSignalIds: [],
|
|
38
|
+
rawContent: `Decision ${index}`,
|
|
39
|
+
createdAt: "2026-05-01T10:00:00.000Z",
|
|
40
|
+
updatedAt: "2026-05-01T10:00:00.000Z",
|
|
41
|
+
uploadedAt: null,
|
|
42
|
+
}));
|
|
43
|
+
const first = analyzeLocalPatterns({ signals, decisions, now: new Date("2026-05-05T10:00:00.000Z") });
|
|
44
|
+
const second = analyzeLocalPatterns({ signals, decisions, now: new Date("2026-05-05T10:00:00.000Z") });
|
|
45
|
+
assert.deepEqual(first, second);
|
|
46
|
+
assert.equal(first.length, 3);
|
|
47
|
+
assert.equal(first[0]?.severity, "high");
|
|
48
|
+
});
|
|
49
|
+
test("tip engine stays quiet on empty input", () => {
|
|
50
|
+
assert.deepEqual(analyzeLocalPatterns({ signals: [], decisions: [] }), []);
|
|
51
|
+
});
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askthew/mcp-plugin",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Ask The W
|
|
5
|
+
"description": "Ask The W plugin connector for local-first coding-agent decisions, signals, and review.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
@@ -11,14 +11,8 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"README.md",
|
|
14
|
-
"dist
|
|
15
|
-
"dist
|
|
16
|
-
"dist/index.d.ts",
|
|
17
|
-
"dist/index.js",
|
|
18
|
-
"dist/install.d.ts",
|
|
19
|
-
"dist/install.js",
|
|
20
|
-
"dist/scope.d.ts",
|
|
21
|
-
"dist/scope.js"
|
|
14
|
+
"dist/**/*.d.ts",
|
|
15
|
+
"dist/**/*.js"
|
|
22
16
|
],
|
|
23
17
|
"keywords": [
|
|
24
18
|
"askthew",
|
|
@@ -57,6 +51,9 @@
|
|
|
57
51
|
},
|
|
58
52
|
"dependencies": {
|
|
59
53
|
"@modelcontextprotocol/sdk": "^1.3.0",
|
|
54
|
+
"better-sqlite3": "^11.10.0",
|
|
55
|
+
"nanoid": "^5.1.5",
|
|
56
|
+
"prompts": "^2.4.2",
|
|
60
57
|
"zod": "^3.24.2"
|
|
61
58
|
},
|
|
62
59
|
"devDependencies": {
|