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.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/App.d.ts +5 -0
- package/dist/App.js +17 -0
- package/dist/detectors/admin.d.ts +2 -0
- package/dist/detectors/admin.js +33 -0
- package/dist/detectors/auth.d.ts +2 -0
- package/dist/detectors/auth.js +86 -0
- package/dist/detectors/database.d.ts +2 -0
- package/dist/detectors/database.js +96 -0
- package/dist/detectors/duplicates.d.ts +2 -0
- package/dist/detectors/duplicates.js +23 -0
- package/dist/detectors/exports.d.ts +2 -0
- package/dist/detectors/exports.js +30 -0
- package/dist/detectors/framework.d.ts +2 -0
- package/dist/detectors/framework.js +71 -0
- package/dist/detectors/index.d.ts +12 -0
- package/dist/detectors/index.js +128 -0
- package/dist/detectors/jobs.d.ts +2 -0
- package/dist/detectors/jobs.js +62 -0
- package/dist/detectors/multiuser.d.ts +2 -0
- package/dist/detectors/multiuser.js +55 -0
- package/dist/detectors/payments.d.ts +2 -0
- package/dist/detectors/payments.js +49 -0
- package/dist/detectors/rules/auth.yaml +24 -0
- package/dist/detectors/rules/database.yaml +27 -0
- package/dist/detectors/rules/exports.yaml +28 -0
- package/dist/detectors/rules/framework.yaml +26 -0
- package/dist/detectors/rules/jobs.yaml +23 -0
- package/dist/detectors/rules/payments.yaml +22 -0
- package/dist/detectors/types.d.ts +2 -0
- package/dist/detectors/types.js +1 -0
- package/dist/detectors/yamlRunner.d.ts +31 -0
- package/dist/detectors/yamlRunner.js +128 -0
- package/dist/engine/cleanup.d.ts +12 -0
- package/dist/engine/cleanup.js +101 -0
- package/dist/engine/compaction.d.ts +5 -0
- package/dist/engine/compaction.js +44 -0
- package/dist/engine/compareSpec.d.ts +2 -0
- package/dist/engine/compareSpec.js +74 -0
- package/dist/engine/computeDrift.d.ts +6 -0
- package/dist/engine/computeDrift.js +133 -0
- package/dist/engine/contextPack.d.ts +4 -0
- package/dist/engine/contextPack.js +169 -0
- package/dist/engine/decisions.d.ts +4 -0
- package/dist/engine/decisions.js +21 -0
- package/dist/engine/diff.d.ts +46 -0
- package/dist/engine/diff.js +210 -0
- package/dist/engine/handoff.d.ts +7 -0
- package/dist/engine/handoff.js +469 -0
- package/dist/engine/status.d.ts +10 -0
- package/dist/engine/status.js +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +299 -0
- package/dist/lib/cli.d.ts +4 -0
- package/dist/lib/cli.js +8 -0
- package/dist/lib/files.d.ts +48 -0
- package/dist/lib/files.js +529 -0
- package/dist/lib/git.d.ts +9 -0
- package/dist/lib/git.js +96 -0
- package/dist/lib/logger.d.ts +3 -0
- package/dist/lib/logger.js +21 -0
- package/dist/lib/ndjson.d.ts +2 -0
- package/dist/lib/ndjson.js +45 -0
- package/dist/lib/notes.d.ts +8 -0
- package/dist/lib/notes.js +144 -0
- package/dist/lib/notify.d.ts +1 -0
- package/dist/lib/notify.js +14 -0
- package/dist/lib/project.d.ts +1 -0
- package/dist/lib/project.js +17 -0
- package/dist/lib/promptSafety.d.ts +1 -0
- package/dist/lib/promptSafety.js +20 -0
- package/dist/lib/signals.d.ts +279 -0
- package/dist/lib/signals.js +55 -0
- package/dist/lib/tty.d.ts +2 -0
- package/dist/lib/tty.js +10 -0
- package/dist/lib/validate.d.ts +9 -0
- package/dist/lib/validate.js +282 -0
- package/dist/lib/yaml.d.ts +4 -0
- package/dist/lib/yaml.js +26 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +259 -0
- package/dist/plain/colors.d.ts +5 -0
- package/dist/plain/colors.js +16 -0
- package/dist/plain/diff.d.ts +1 -0
- package/dist/plain/diff.js +129 -0
- package/dist/plain/handoff.d.ts +1 -0
- package/dist/plain/handoff.js +9 -0
- package/dist/plain/init.d.ts +1 -0
- package/dist/plain/init.js +44 -0
- package/dist/plain/notes.d.ts +5 -0
- package/dist/plain/notes.js +49 -0
- package/dist/plain/status.d.ts +2 -0
- package/dist/plain/status.js +13 -0
- package/dist/plain/watch.d.ts +1 -0
- package/dist/plain/watch.js +78 -0
- package/dist/ui/CleanupPlan.d.ts +5 -0
- package/dist/ui/CleanupPlan.js +8 -0
- package/dist/ui/DetectorSweep.d.ts +6 -0
- package/dist/ui/DetectorSweep.js +54 -0
- package/dist/ui/DriftAlert.d.ts +7 -0
- package/dist/ui/DriftAlert.js +105 -0
- package/dist/ui/Handoff.d.ts +1 -0
- package/dist/ui/Handoff.js +37 -0
- package/dist/ui/Init.d.ts +1 -0
- package/dist/ui/Init.js +117 -0
- package/dist/ui/Logo.d.ts +1 -0
- package/dist/ui/Logo.js +13 -0
- package/dist/ui/SpecSummary.d.ts +8 -0
- package/dist/ui/SpecSummary.js +15 -0
- package/dist/ui/Status.d.ts +1 -0
- package/dist/ui/Status.js +38 -0
- package/dist/ui/Watch.d.ts +1 -0
- package/dist/ui/Watch.js +136 -0
- package/dist/yoga.wasm +0 -0
- package/package.json +50 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { grepFiles, listProjectFiles, readJson, fileExists } from "../lib/files.js";
|
|
2
|
+
const SYSTEM_CLEANUP_MAP = {
|
|
3
|
+
auth: {
|
|
4
|
+
packages: [
|
|
5
|
+
"@clerk/nextjs",
|
|
6
|
+
"@clerk/clerk-react",
|
|
7
|
+
"next-auth",
|
|
8
|
+
"@auth/core",
|
|
9
|
+
"@auth0/nextjs-auth0",
|
|
10
|
+
"passport",
|
|
11
|
+
"lucia",
|
|
12
|
+
"@supabase/auth-helpers-nextjs",
|
|
13
|
+
],
|
|
14
|
+
grepPatterns: [/ClerkProvider|NextAuth|Auth0Provider|passport\.authenticate|Lucia/],
|
|
15
|
+
configFiles: ["auth.ts", "auth.config.ts", "src/auth.ts", "middleware.ts"],
|
|
16
|
+
},
|
|
17
|
+
payments: {
|
|
18
|
+
packages: [
|
|
19
|
+
"stripe",
|
|
20
|
+
"@stripe/stripe-js",
|
|
21
|
+
"@stripe/react-stripe-js",
|
|
22
|
+
"@paddle/paddle-js",
|
|
23
|
+
"@lemonsqueezy/lemonsqueezy.js",
|
|
24
|
+
],
|
|
25
|
+
grepPatterns: [/stripe|Stripe|paddle|lemonSqueezy/i],
|
|
26
|
+
configFiles: [],
|
|
27
|
+
},
|
|
28
|
+
db: {
|
|
29
|
+
packages: [
|
|
30
|
+
"prisma",
|
|
31
|
+
"@prisma/client",
|
|
32
|
+
"drizzle-orm",
|
|
33
|
+
"drizzle-kit",
|
|
34
|
+
"typeorm",
|
|
35
|
+
"mongoose",
|
|
36
|
+
"knex",
|
|
37
|
+
"pg",
|
|
38
|
+
"mysql2",
|
|
39
|
+
],
|
|
40
|
+
grepPatterns: [/prisma|drizzle|typeorm|mongoose|knex/i],
|
|
41
|
+
configFiles: ["prisma/schema.prisma", "drizzle.config.ts", "ormconfig.json"],
|
|
42
|
+
},
|
|
43
|
+
multi_tenant: {
|
|
44
|
+
packages: [],
|
|
45
|
+
grepPatterns: [/Organization|orgId|teamId|workspaceId|tenantId/],
|
|
46
|
+
configFiles: ["prisma/schema.prisma"],
|
|
47
|
+
},
|
|
48
|
+
admin_panel: {
|
|
49
|
+
packages: [],
|
|
50
|
+
grepPatterns: [/isAdmin|requireAdmin|adminOnly|AdminGuard/],
|
|
51
|
+
configFiles: [],
|
|
52
|
+
},
|
|
53
|
+
background_jobs: {
|
|
54
|
+
packages: ["bullmq", "bull", "agenda", "node-cron", "cron", "inngest", "@trigger.dev/sdk"],
|
|
55
|
+
grepPatterns: [/Queue|Worker|Bull|agenda|cron\.schedule/i],
|
|
56
|
+
configFiles: [],
|
|
57
|
+
},
|
|
58
|
+
exports: {
|
|
59
|
+
packages: ["jspdf", "pdfkit", "@react-pdf/renderer", "puppeteer", "playwright", "exceljs", "xlsx"],
|
|
60
|
+
grepPatterns: [/jsPDF|PDFDocument|renderToStream/],
|
|
61
|
+
configFiles: [],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
export function generateCleanupPlan(systemId) {
|
|
65
|
+
const mapping = SYSTEM_CLEANUP_MAP[systemId];
|
|
66
|
+
if (!mapping) {
|
|
67
|
+
return {
|
|
68
|
+
system: systemId,
|
|
69
|
+
packagesToRemove: [],
|
|
70
|
+
filesToReview: [],
|
|
71
|
+
configFilesToCheck: [],
|
|
72
|
+
summary: `No cleanup mapping found for system "${systemId}". Manual review required.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const pkg = readJson("package.json");
|
|
76
|
+
const allDeps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
77
|
+
const packagesToRemove = mapping.packages.filter((p) => p in allDeps);
|
|
78
|
+
const projectFiles = listProjectFiles();
|
|
79
|
+
const filesToReview = mapping.grepPatterns.flatMap((pattern) => grepFiles(projectFiles, pattern, 20));
|
|
80
|
+
const uniqueFiles = new Map();
|
|
81
|
+
for (const match of filesToReview) {
|
|
82
|
+
const key = `${match.file}:${match.line}`;
|
|
83
|
+
if (!uniqueFiles.has(key))
|
|
84
|
+
uniqueFiles.set(key, match);
|
|
85
|
+
}
|
|
86
|
+
const configFilesToCheck = mapping.configFiles.filter((f) => fileExists(f));
|
|
87
|
+
const summary = [
|
|
88
|
+
packagesToRemove.length > 0 ? `Remove ${packagesToRemove.length} package(s): ${packagesToRemove.join(", ")}` : null,
|
|
89
|
+
uniqueFiles.size > 0 ? `Review ${uniqueFiles.size} file reference(s)` : null,
|
|
90
|
+
configFilesToCheck.length > 0 ? `Check ${configFilesToCheck.length} config file(s)` : null,
|
|
91
|
+
]
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join(". ");
|
|
94
|
+
return {
|
|
95
|
+
system: systemId,
|
|
96
|
+
packagesToRemove,
|
|
97
|
+
filesToReview: Array.from(uniqueFiles.values()),
|
|
98
|
+
configFilesToCheck,
|
|
99
|
+
summary: summary || "No cleanup actions identified.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { handoffsDirPath } from "../lib/files.js";
|
|
4
|
+
import { log } from "../lib/logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* Archives handoff pairs (`.json` + `.md`) older than the most recent `keepRecent`.
|
|
7
|
+
* Moves them into `.tack/handoffs/archive/` so pairs are never split.
|
|
8
|
+
*/
|
|
9
|
+
export function archiveOldHandoffs(keepRecent = 10) {
|
|
10
|
+
const handoffsDir = handoffsDirPath();
|
|
11
|
+
if (!existsSync(handoffsDir))
|
|
12
|
+
return;
|
|
13
|
+
const entries = readdirSync(handoffsDir, { withFileTypes: true });
|
|
14
|
+
const files = entries
|
|
15
|
+
.filter((e) => e.isFile() && (e.name.endsWith(".json") || e.name.endsWith(".md")))
|
|
16
|
+
.map((e) => e.name);
|
|
17
|
+
// Group by stem (basename without extension) so we keep/archive whole pairs
|
|
18
|
+
const byStem = new Map();
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const stem = path.basename(f, path.extname(f));
|
|
21
|
+
const list = byStem.get(stem) ?? [];
|
|
22
|
+
list.push(f);
|
|
23
|
+
byStem.set(stem, list);
|
|
24
|
+
}
|
|
25
|
+
const stems = Array.from(byStem.keys()).sort().reverse();
|
|
26
|
+
const toKeep = stems.slice(0, keepRecent);
|
|
27
|
+
const toArchive = stems.slice(keepRecent);
|
|
28
|
+
if (toArchive.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
const archiveDir = path.join(handoffsDir, "archive");
|
|
31
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
32
|
+
let archivedCount = 0;
|
|
33
|
+
for (const stem of toArchive) {
|
|
34
|
+
for (const file of byStem.get(stem) ?? []) {
|
|
35
|
+
renameSync(path.join(handoffsDir, file), path.join(archiveDir, file));
|
|
36
|
+
archivedCount++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
log({
|
|
40
|
+
event: "compaction:archive_handoffs",
|
|
41
|
+
archived_count: archivedCount,
|
|
42
|
+
kept_count: toKeep.length,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export function compareSpec(signals, spec) {
|
|
2
|
+
const systems = signals.filter((s) => s.category === "system");
|
|
3
|
+
const scopes = signals.filter((s) => s.category === "scope");
|
|
4
|
+
const risks = signals.filter((s) => s.category === "risk");
|
|
5
|
+
const aligned = [];
|
|
6
|
+
const violations = [];
|
|
7
|
+
const undeclared = [];
|
|
8
|
+
for (const sig of systems) {
|
|
9
|
+
if (spec.allowed_systems.includes(sig.id)) {
|
|
10
|
+
aligned.push(sig);
|
|
11
|
+
}
|
|
12
|
+
else if (spec.forbidden_systems.includes(sig.id)) {
|
|
13
|
+
violations.push({
|
|
14
|
+
type: "forbidden_system",
|
|
15
|
+
signal: sig,
|
|
16
|
+
spec_rule: `forbidden_systems contains "${sig.id}"`,
|
|
17
|
+
severity: "error",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
undeclared.push(sig);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const sig of scopes) {
|
|
25
|
+
if (spec.forbidden_systems.includes(sig.id)) {
|
|
26
|
+
violations.push({
|
|
27
|
+
type: "forbidden_system",
|
|
28
|
+
signal: sig,
|
|
29
|
+
spec_rule: `forbidden_systems contains "${sig.id}"`,
|
|
30
|
+
severity: "error",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else if (!spec.allowed_systems.includes(sig.id)) {
|
|
34
|
+
undeclared.push(sig);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
aligned.push(sig);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const [key, expectedValue] of Object.entries(spec.constraints)) {
|
|
41
|
+
const matchingSignal = systems.find((s) => {
|
|
42
|
+
if (key === "framework")
|
|
43
|
+
return s.id === "framework";
|
|
44
|
+
if (key === "db")
|
|
45
|
+
return s.id === "db";
|
|
46
|
+
if (key === "auth")
|
|
47
|
+
return s.id === "auth";
|
|
48
|
+
if (key === "deploy")
|
|
49
|
+
return s.id === "deploy";
|
|
50
|
+
return false;
|
|
51
|
+
});
|
|
52
|
+
if (matchingSignal?.detail) {
|
|
53
|
+
const detectedDetail = matchingSignal.detail.toLowerCase();
|
|
54
|
+
const expected = expectedValue.toLowerCase();
|
|
55
|
+
if (!detectedDetail.includes(expected)) {
|
|
56
|
+
violations.push({
|
|
57
|
+
type: "constraint_mismatch",
|
|
58
|
+
signal: matchingSignal,
|
|
59
|
+
spec_rule: `constraints.${key} expects "${expectedValue}" but found "${matchingSignal.detail}"`,
|
|
60
|
+
severity: "error",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const detectedIds = new Set([...systems.map((s) => s.id), ...scopes.map((s) => s.id)]);
|
|
66
|
+
const missing = spec.allowed_systems.filter((id) => !detectedIds.has(id));
|
|
67
|
+
return {
|
|
68
|
+
aligned,
|
|
69
|
+
violations,
|
|
70
|
+
undeclared,
|
|
71
|
+
missing,
|
|
72
|
+
risks,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SpecDiff, DriftState, DriftItem } from "../lib/signals.js";
|
|
2
|
+
export declare function computeDrift(diff: SpecDiff): {
|
|
3
|
+
newItems: DriftItem[];
|
|
4
|
+
state: DriftState;
|
|
5
|
+
};
|
|
6
|
+
export declare function resolveDriftItem(id: string, action: "accepted" | "rejected" | "skipped", note?: string): DriftState;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createDriftId } from "../lib/signals.js";
|
|
2
|
+
import { readDrift, writeDrift } from "../lib/files.js";
|
|
3
|
+
import { log } from "../lib/logger.js";
|
|
4
|
+
export function computeDrift(diff) {
|
|
5
|
+
const existing = readDrift();
|
|
6
|
+
const newItems = [];
|
|
7
|
+
const existingFingerprints = new Set(existing.items.map((item) => fingerprint(item)));
|
|
8
|
+
// Build the set of drift fingerprints that are still present in the latest spec diff.
|
|
9
|
+
const currentFingerprints = new Set();
|
|
10
|
+
for (const violation of diff.violations) {
|
|
11
|
+
const type = violation.type === "forbidden_system" ? "forbidden_system_detected" : "constraint_mismatch";
|
|
12
|
+
const fpItem = {
|
|
13
|
+
id: "",
|
|
14
|
+
type,
|
|
15
|
+
system: violation.signal.id,
|
|
16
|
+
signal: "",
|
|
17
|
+
detected: "",
|
|
18
|
+
status: "unresolved",
|
|
19
|
+
};
|
|
20
|
+
currentFingerprints.add(fingerprint(fpItem));
|
|
21
|
+
}
|
|
22
|
+
for (const risk of diff.risks) {
|
|
23
|
+
const fpItem = {
|
|
24
|
+
id: "",
|
|
25
|
+
type: "risk",
|
|
26
|
+
risk: risk.id,
|
|
27
|
+
signal: "",
|
|
28
|
+
detected: "",
|
|
29
|
+
status: "unresolved",
|
|
30
|
+
};
|
|
31
|
+
currentFingerprints.add(fingerprint(fpItem));
|
|
32
|
+
}
|
|
33
|
+
for (const sig of diff.undeclared) {
|
|
34
|
+
const fpItem = {
|
|
35
|
+
id: "",
|
|
36
|
+
type: "undeclared_system",
|
|
37
|
+
system: sig.id,
|
|
38
|
+
signal: "",
|
|
39
|
+
detected: "",
|
|
40
|
+
status: "unresolved",
|
|
41
|
+
};
|
|
42
|
+
currentFingerprints.add(fingerprint(fpItem));
|
|
43
|
+
}
|
|
44
|
+
// Automatically resolve drift items whose underlying fingerprint is no longer present.
|
|
45
|
+
for (const item of existing.items) {
|
|
46
|
+
const fp = fingerprint(item);
|
|
47
|
+
if (item.status === "unresolved" && !currentFingerprints.has(fp)) {
|
|
48
|
+
item.status = "rejected";
|
|
49
|
+
log({
|
|
50
|
+
event: "drift:resolved",
|
|
51
|
+
system: item.system ?? item.risk ?? item.type,
|
|
52
|
+
message: item.signal,
|
|
53
|
+
source: ".tack/_drift.yaml",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const violation of diff.violations) {
|
|
58
|
+
const item = {
|
|
59
|
+
id: createDriftId(),
|
|
60
|
+
type: violation.type === "forbidden_system" ? "forbidden_system_detected" : "constraint_mismatch",
|
|
61
|
+
system: violation.signal.id,
|
|
62
|
+
signal: `${violation.signal.detail ?? violation.signal.id}: ${violation.signal.source}`,
|
|
63
|
+
detected: new Date().toISOString(),
|
|
64
|
+
status: "unresolved",
|
|
65
|
+
};
|
|
66
|
+
if (!existingFingerprints.has(fingerprint(item))) {
|
|
67
|
+
newItems.push(item);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const risk of diff.risks) {
|
|
71
|
+
const item = {
|
|
72
|
+
id: createDriftId(),
|
|
73
|
+
type: "risk",
|
|
74
|
+
risk: risk.id,
|
|
75
|
+
signal: `${risk.detail ?? risk.id}: ${risk.source}`,
|
|
76
|
+
detected: new Date().toISOString(),
|
|
77
|
+
status: "unresolved",
|
|
78
|
+
};
|
|
79
|
+
if (!existingFingerprints.has(fingerprint(item))) {
|
|
80
|
+
newItems.push(item);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const sig of diff.undeclared) {
|
|
84
|
+
const item = {
|
|
85
|
+
id: createDriftId(),
|
|
86
|
+
type: "undeclared_system",
|
|
87
|
+
system: sig.id,
|
|
88
|
+
signal: `${sig.detail ?? sig.id}: ${sig.source}`,
|
|
89
|
+
detected: new Date().toISOString(),
|
|
90
|
+
status: "unresolved",
|
|
91
|
+
};
|
|
92
|
+
if (!existingFingerprints.has(fingerprint(item))) {
|
|
93
|
+
newItems.push(item);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const state = {
|
|
97
|
+
items: [...existing.items, ...newItems],
|
|
98
|
+
};
|
|
99
|
+
writeDrift(state);
|
|
100
|
+
for (const item of newItems) {
|
|
101
|
+
log({
|
|
102
|
+
event: "drift:detected",
|
|
103
|
+
system: item.system ?? item.risk ?? item.type,
|
|
104
|
+
message: item.signal,
|
|
105
|
+
source: ".tack/_drift.yaml",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return { newItems, state };
|
|
109
|
+
}
|
|
110
|
+
export function resolveDriftItem(id, action, note) {
|
|
111
|
+
const state = readDrift();
|
|
112
|
+
const item = state.items.find((i) => i.id === id);
|
|
113
|
+
let previousStatus = null;
|
|
114
|
+
if (item) {
|
|
115
|
+
previousStatus = item.status;
|
|
116
|
+
item.status = action === "skipped" ? "unresolved" : action;
|
|
117
|
+
if (note)
|
|
118
|
+
item.note = note;
|
|
119
|
+
}
|
|
120
|
+
writeDrift(state);
|
|
121
|
+
if (item && previousStatus === "unresolved" && item.status !== "unresolved") {
|
|
122
|
+
log({
|
|
123
|
+
event: "drift:resolved",
|
|
124
|
+
system: item.system ?? item.risk ?? item.type,
|
|
125
|
+
message: item.signal,
|
|
126
|
+
source: ".tack/_drift.yaml",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return state;
|
|
130
|
+
}
|
|
131
|
+
function fingerprint(item) {
|
|
132
|
+
return `${item.type}:${item.system ?? ""}:${item.risk ?? ""}`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ContextLineRef, ContextPack, DecisionEntry } from "../lib/signals.js";
|
|
2
|
+
export declare function parseDecisionsMarkdown(content: string, file?: string): DecisionEntry[];
|
|
3
|
+
export declare function parseContextPack(): ContextPack;
|
|
4
|
+
export declare function contextRefToString(ref: ContextLineRef): string;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { assumptionsPath, contextPath, implementationStatusPath, decisionsPath, goalsPath, openQuestionsPath, readFile, } from "../lib/files.js";
|
|
2
|
+
function parseBulletsInSection(content, sectionName, file) {
|
|
3
|
+
const lines = content.split("\n");
|
|
4
|
+
const out = [];
|
|
5
|
+
let inSection = false;
|
|
6
|
+
const target = `## ${sectionName}`.toLowerCase();
|
|
7
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
8
|
+
const raw = lines[i] ?? "";
|
|
9
|
+
const line = raw.trim();
|
|
10
|
+
if (line.toLowerCase() === target) {
|
|
11
|
+
inSection = true;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (inSection && line.startsWith("## ")) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
if (inSection && line.startsWith("- ")) {
|
|
18
|
+
const text = line.slice(2).trim();
|
|
19
|
+
if (text.length > 0) {
|
|
20
|
+
out.push({ text, source: { file, line: i + 1 } });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function parseQuestionStatus(line) {
|
|
27
|
+
const match = line.match(/^\[(open|resolved)\]\s*(.*)$/i);
|
|
28
|
+
if (!match) {
|
|
29
|
+
return { status: "unknown", text: line.trim() };
|
|
30
|
+
}
|
|
31
|
+
const rawStatus = (match[1] ?? "").toLowerCase();
|
|
32
|
+
const status = rawStatus === "open" || rawStatus === "resolved" ? rawStatus : "unknown";
|
|
33
|
+
return {
|
|
34
|
+
status,
|
|
35
|
+
text: (match[2] ?? "").trim(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function parseQuestionBullets(content, file) {
|
|
39
|
+
const lines = content.split("\n");
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
42
|
+
const line = (lines[i] ?? "").trim();
|
|
43
|
+
if (!line.startsWith("- "))
|
|
44
|
+
continue;
|
|
45
|
+
const payload = line.slice(2).trim();
|
|
46
|
+
if (!payload)
|
|
47
|
+
continue;
|
|
48
|
+
const parsed = parseQuestionStatus(payload);
|
|
49
|
+
if (parsed.text.length === 0)
|
|
50
|
+
continue;
|
|
51
|
+
out.push({
|
|
52
|
+
status: parsed.status,
|
|
53
|
+
text: parsed.text,
|
|
54
|
+
source: { file, line: i + 1 },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function parseDecisionsMarkdown(content, file = ".tack/decisions.md") {
|
|
60
|
+
if (!content)
|
|
61
|
+
return [];
|
|
62
|
+
const lines = content.split("\n");
|
|
63
|
+
const out = [];
|
|
64
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
65
|
+
const line = (lines[i] ?? "").trim();
|
|
66
|
+
if (!line.startsWith("- "))
|
|
67
|
+
continue;
|
|
68
|
+
const m = line.match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.+?)\s*—\s*(.+)$/);
|
|
69
|
+
if (!m)
|
|
70
|
+
continue;
|
|
71
|
+
out.push({
|
|
72
|
+
date: m[1] ?? "",
|
|
73
|
+
decision: (m[2] ?? "").trim(),
|
|
74
|
+
reasoning: (m[3] ?? "").trim(),
|
|
75
|
+
source: { file, line: i + 1 },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
function parseDecisionsFile() {
|
|
81
|
+
const content = readFile(decisionsPath());
|
|
82
|
+
if (!content)
|
|
83
|
+
return [];
|
|
84
|
+
return parseDecisionsMarkdown(content, ".tack/decisions.md");
|
|
85
|
+
}
|
|
86
|
+
function parseContextFile() {
|
|
87
|
+
const file = ".tack/context.md";
|
|
88
|
+
const content = readFile(contextPath());
|
|
89
|
+
if (!content)
|
|
90
|
+
return [];
|
|
91
|
+
return parseBulletsInSection(content, "North Star", file);
|
|
92
|
+
}
|
|
93
|
+
function parseGoalsFile() {
|
|
94
|
+
const file = ".tack/goals.md";
|
|
95
|
+
const content = readFile(goalsPath());
|
|
96
|
+
if (!content)
|
|
97
|
+
return { goals: [], nonGoals: [] };
|
|
98
|
+
return {
|
|
99
|
+
goals: parseBulletsInSection(content, "Goals", file),
|
|
100
|
+
nonGoals: parseBulletsInSection(content, "Non-Goals", file),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function parseAssumptionsFile() {
|
|
104
|
+
const file = ".tack/assumptions.md";
|
|
105
|
+
const content = readFile(assumptionsPath());
|
|
106
|
+
if (!content)
|
|
107
|
+
return [];
|
|
108
|
+
return parseQuestionBullets(content, file);
|
|
109
|
+
}
|
|
110
|
+
function parseOpenQuestionsFile() {
|
|
111
|
+
const file = ".tack/open_questions.md";
|
|
112
|
+
const content = readFile(openQuestionsPath());
|
|
113
|
+
if (!content)
|
|
114
|
+
return [];
|
|
115
|
+
return parseQuestionBullets(content, file);
|
|
116
|
+
}
|
|
117
|
+
function parseImplementationStatusFile() {
|
|
118
|
+
const file = ".tack/implementation_status.md";
|
|
119
|
+
const content = readFile(implementationStatusPath());
|
|
120
|
+
if (!content)
|
|
121
|
+
return [];
|
|
122
|
+
const lines = content.split("\n");
|
|
123
|
+
const out = [];
|
|
124
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
125
|
+
const line = (lines[i] ?? "").trim();
|
|
126
|
+
if (!line.startsWith("- "))
|
|
127
|
+
continue;
|
|
128
|
+
const payload = line.slice(2).trim();
|
|
129
|
+
if (!payload)
|
|
130
|
+
continue;
|
|
131
|
+
const m = payload.match(/^([a-z0-9_.-]+)\s*:\s*(implemented|pending|unknown)\s*(?:\((.+)\))?$/i);
|
|
132
|
+
if (!m)
|
|
133
|
+
continue;
|
|
134
|
+
const key = (m[1] ?? "").trim();
|
|
135
|
+
const status = (m[2] ?? "").toLowerCase() || "unknown";
|
|
136
|
+
const anchorsRaw = (m[3] ?? "").trim();
|
|
137
|
+
const anchors = anchorsRaw.length === 0
|
|
138
|
+
? []
|
|
139
|
+
: anchorsRaw
|
|
140
|
+
.split(",")
|
|
141
|
+
.map((a) => a.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
if (!key)
|
|
144
|
+
continue;
|
|
145
|
+
out.push({
|
|
146
|
+
key,
|
|
147
|
+
status,
|
|
148
|
+
anchors,
|
|
149
|
+
source: { file, line: i + 1 },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
export function parseContextPack() {
|
|
155
|
+
const northStar = parseContextFile();
|
|
156
|
+
const goals = parseGoalsFile();
|
|
157
|
+
return {
|
|
158
|
+
north_star: northStar,
|
|
159
|
+
goals: goals.goals,
|
|
160
|
+
non_goals: goals.nonGoals,
|
|
161
|
+
assumptions: parseAssumptionsFile(),
|
|
162
|
+
open_questions: parseOpenQuestionsFile(),
|
|
163
|
+
implementation_status: parseImplementationStatusFile(),
|
|
164
|
+
decisions: parseDecisionsFile(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export function contextRefToString(ref) {
|
|
168
|
+
return `${ref.file}:${ref.line}`;
|
|
169
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DecisionActor } from "../lib/signals.js";
|
|
2
|
+
export declare function appendDecision(decision: string, reasoning: string): void;
|
|
3
|
+
export declare function readDecisionsMarkdown(): string;
|
|
4
|
+
export declare function normalizeDecisionActor(raw: string | undefined): DecisionActor;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { appendSafe, decisionsPath, readFile, ensureContextTemplates } from "../lib/files.js";
|
|
2
|
+
function todayIsoDate() {
|
|
3
|
+
return new Date().toISOString().slice(0, 10);
|
|
4
|
+
}
|
|
5
|
+
export function appendDecision(decision, reasoning) {
|
|
6
|
+
ensureContextTemplates();
|
|
7
|
+
const line = `- [${todayIsoDate()}] ${decision} — ${reasoning}\n`;
|
|
8
|
+
appendSafe(decisionsPath(), line);
|
|
9
|
+
}
|
|
10
|
+
export function readDecisionsMarkdown() {
|
|
11
|
+
ensureContextTemplates();
|
|
12
|
+
return readFile(decisionsPath()) ?? "# Decisions\n";
|
|
13
|
+
}
|
|
14
|
+
export function normalizeDecisionActor(raw) {
|
|
15
|
+
if (!raw || raw.trim() === "")
|
|
16
|
+
return "user";
|
|
17
|
+
const value = raw.trim();
|
|
18
|
+
if (value === "user" || value.startsWith("agent:"))
|
|
19
|
+
return value;
|
|
20
|
+
return "user";
|
|
21
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type DriftItem, type DriftState, type DecisionEntry, type Spec } from "../lib/signals.js";
|
|
2
|
+
export type ArchSystem = {
|
|
3
|
+
id: string;
|
|
4
|
+
detail?: string;
|
|
5
|
+
};
|
|
6
|
+
export type ArchSnapshot = {
|
|
7
|
+
ref: string;
|
|
8
|
+
spec: Spec | null;
|
|
9
|
+
systems: ArchSystem[];
|
|
10
|
+
drift: DriftState | null;
|
|
11
|
+
decisions: DecisionEntry[];
|
|
12
|
+
hasSpec: boolean;
|
|
13
|
+
hasAudit: boolean;
|
|
14
|
+
hasDrift: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type DriftResolutionChange = {
|
|
17
|
+
id: string;
|
|
18
|
+
before: DriftItem | null;
|
|
19
|
+
after: DriftItem | null;
|
|
20
|
+
};
|
|
21
|
+
export type ArchDiff = {
|
|
22
|
+
baseRef: string;
|
|
23
|
+
headRef: string;
|
|
24
|
+
systems: {
|
|
25
|
+
available: boolean;
|
|
26
|
+
added: ArchSystem[];
|
|
27
|
+
removed: ArchSystem[];
|
|
28
|
+
changed: Array<{
|
|
29
|
+
id: string;
|
|
30
|
+
before: ArchSystem;
|
|
31
|
+
after: ArchSystem;
|
|
32
|
+
}>;
|
|
33
|
+
};
|
|
34
|
+
drift: {
|
|
35
|
+
available: boolean;
|
|
36
|
+
newlyUnresolved: DriftItem[];
|
|
37
|
+
resolved: DriftResolutionChange[];
|
|
38
|
+
};
|
|
39
|
+
decisions: {
|
|
40
|
+
newDecisions: DecisionEntry[];
|
|
41
|
+
};
|
|
42
|
+
warnings: string[];
|
|
43
|
+
};
|
|
44
|
+
export declare function computeArchSnapshotFromWorkingTree(): ArchSnapshot;
|
|
45
|
+
export declare function computeArchSnapshotFromRef(ref: string): ArchSnapshot;
|
|
46
|
+
export declare function computeArchDiff(baseBranch: string): ArchDiff;
|