@web42/stask 0.1.5
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 +100 -0
- package/bin/stask.mjs +157 -0
- package/commands/approve.mjs +43 -0
- package/commands/assign.mjs +64 -0
- package/commands/create.mjs +88 -0
- package/commands/delete.mjs +127 -0
- package/commands/heartbeat.mjs +375 -0
- package/commands/list.mjs +60 -0
- package/commands/log.mjs +34 -0
- package/commands/pr-status.mjs +33 -0
- package/commands/qa.mjs +183 -0
- package/commands/session.mjs +76 -0
- package/commands/show.mjs +61 -0
- package/commands/spec-update.mjs +61 -0
- package/commands/subtask-create.mjs +62 -0
- package/commands/subtask-done.mjs +80 -0
- package/commands/sync-daemon.mjs +134 -0
- package/commands/sync.mjs +36 -0
- package/commands/transition.mjs +143 -0
- package/config.example.json +55 -0
- package/lib/env.mjs +118 -0
- package/lib/file-uploader.mjs +179 -0
- package/lib/guards.mjs +261 -0
- package/lib/pr-create.mjs +133 -0
- package/lib/pr-status.mjs +119 -0
- package/lib/roles.mjs +85 -0
- package/lib/session-tracker.mjs +150 -0
- package/lib/slack-api.mjs +198 -0
- package/lib/slack-row.mjs +257 -0
- package/lib/slack-sync.mjs +388 -0
- package/lib/sync-daemon.mjs +117 -0
- package/lib/tracker-db.mjs +473 -0
- package/lib/tx.mjs +84 -0
- package/lib/validate.mjs +90 -0
- package/lib/worktree-cleanup.mjs +91 -0
- package/lib/worktree-create.mjs +127 -0
- package/package.json +34 -0
- package/skills/stask-general.md +113 -0
- package/skills/stask-lead.md +72 -0
- package/skills/stask-qa.md +99 -0
- package/skills/stask-worker.md +61 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sync.mjs — Run one bidirectional sync cycle (Slack ↔ DB).
|
|
3
|
+
*
|
|
4
|
+
* Usage: stask sync [--json]
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runSyncCycle } from '../lib/slack-sync.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run(argv) {
|
|
10
|
+
const json = argv.includes('--json');
|
|
11
|
+
|
|
12
|
+
const summary = await runSyncCycle();
|
|
13
|
+
|
|
14
|
+
if (json) {
|
|
15
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Human-readable output
|
|
20
|
+
if (summary.pulled.length > 0) {
|
|
21
|
+
console.log(`Pulled from Slack: ${summary.pulled.join(', ')}`);
|
|
22
|
+
}
|
|
23
|
+
if (summary.pushed.length > 0) {
|
|
24
|
+
console.log(`Pushed to Slack: ${summary.pushed.join(', ')}`);
|
|
25
|
+
}
|
|
26
|
+
if (summary.deleted.length > 0) {
|
|
27
|
+
console.log(`Deleted (removed from Slack): ${summary.deleted.join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
if (summary.errors.length > 0) {
|
|
30
|
+
console.error(`Errors:`);
|
|
31
|
+
for (const err of summary.errors) console.error(` - ${err}`);
|
|
32
|
+
}
|
|
33
|
+
if (summary.pulled.length === 0 && summary.pushed.length === 0 && summary.errors.length === 0) {
|
|
34
|
+
console.log(`In sync. (${summary.skipped} tasks checked)`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stask transition — Transition a task's status with DB-enforced validation.
|
|
3
|
+
*
|
|
4
|
+
* Usage: stask transition <task-id> <new-status>
|
|
5
|
+
*
|
|
6
|
+
* Handles side effects: worktree creation, PR creation, cleanup,
|
|
7
|
+
* auto-assignment, and subtask cascading.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { execFileSync } from 'child_process';
|
|
13
|
+
import { CONFIG, LIB_DIR, getWorkspaceLibs } from '../lib/env.mjs';
|
|
14
|
+
import { withTransaction } from '../lib/tx.mjs';
|
|
15
|
+
import { syncTaskToSlack } from '../lib/slack-row.mjs';
|
|
16
|
+
import { getAutoAssign } from '../lib/roles.mjs';
|
|
17
|
+
import { runGuards } from '../lib/guards.mjs';
|
|
18
|
+
|
|
19
|
+
const TRIGGER_SQL = `
|
|
20
|
+
CREATE TRIGGER validate_status_transition
|
|
21
|
+
BEFORE UPDATE OF status ON tasks WHEN OLD.status != NEW.status
|
|
22
|
+
BEGIN SELECT CASE
|
|
23
|
+
WHEN OLD.status = 'Done' THEN RAISE(ABORT, 'Cannot transition from Done')
|
|
24
|
+
WHEN OLD.status = 'To-Do' AND NEW.status NOT IN ('In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from To-Do')
|
|
25
|
+
WHEN OLD.status = 'In-Progress' AND NEW.status NOT IN ('Testing','Blocked') THEN RAISE(ABORT, 'Invalid transition from In-Progress')
|
|
26
|
+
WHEN OLD.status = 'Testing' AND NEW.status NOT IN ('Ready for Human Review','In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from Testing')
|
|
27
|
+
WHEN OLD.status = 'Ready for Human Review' AND NEW.status NOT IN ('Done','In-Progress','Blocked') THEN RAISE(ABORT, 'Invalid transition from Ready for Human Review')
|
|
28
|
+
WHEN OLD.status = 'Blocked' AND NEW.status NOT IN ('To-Do','In-Progress','Testing','Ready for Human Review') THEN RAISE(ABORT, 'Invalid transition from Blocked')
|
|
29
|
+
END; END;
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
export async function run(argv) {
|
|
33
|
+
const taskId = argv[0];
|
|
34
|
+
const newStatus = argv[1];
|
|
35
|
+
const libs = await getWorkspaceLibs();
|
|
36
|
+
const { STATUSES } = libs.validate;
|
|
37
|
+
|
|
38
|
+
if (!taskId || !newStatus) {
|
|
39
|
+
console.error('Usage: stask transition <task-id> <new-status>');
|
|
40
|
+
console.error(`Valid statuses: ${STATUSES.join(', ')}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!STATUSES.includes(newStatus)) {
|
|
45
|
+
console.error(`ERROR: Unknown status "${newStatus}". Valid: ${STATUSES.join(', ')}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const task = libs.trackerDb.findTask(taskId);
|
|
50
|
+
if (!task) { console.error(`ERROR: Task ${taskId} not found`); process.exit(1); }
|
|
51
|
+
|
|
52
|
+
const oldStatus = task['Status'];
|
|
53
|
+
const isParent = task['Parent'] === 'None';
|
|
54
|
+
|
|
55
|
+
// Run guards (checks + setup side effects like worktree/PR creation)
|
|
56
|
+
const { ok, failures } = runGuards(task, newStatus, libs);
|
|
57
|
+
if (!ok) {
|
|
58
|
+
console.error(`\nTransition ${taskId}: ${oldStatus} → ${newStatus} BLOCKED by ${failures.length} guard(s).`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const autoAssign = getAutoAssign(newStatus);
|
|
63
|
+
|
|
64
|
+
const result = await withTransaction(
|
|
65
|
+
(db, libs) => {
|
|
66
|
+
const updates = { status: newStatus };
|
|
67
|
+
if (autoAssign) updates.assigned_to = autoAssign;
|
|
68
|
+
|
|
69
|
+
// Clear PR status when moving to Testing or RHR (feedback addressed)
|
|
70
|
+
if (newStatus === 'Testing' || newStatus === 'Ready for Human Review') {
|
|
71
|
+
updates.pr_status = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
libs.trackerDb.updateTask(taskId, updates);
|
|
75
|
+
|
|
76
|
+
const assignMsg = autoAssign ? ` Assigned: ${autoAssign}.` : '';
|
|
77
|
+
libs.trackerDb.addLogEntry(taskId, `${taskId} "${task['Task Name']}": ${oldStatus} → ${newStatus}.${assignMsg}`);
|
|
78
|
+
|
|
79
|
+
// Cascade to subtasks
|
|
80
|
+
const subtasks = libs.trackerDb.getSubtasks(taskId);
|
|
81
|
+
const cascaded = [];
|
|
82
|
+
if (subtasks.length > 0) {
|
|
83
|
+
db.exec('DROP TRIGGER IF EXISTS validate_status_transition');
|
|
84
|
+
try {
|
|
85
|
+
for (const sub of subtasks) {
|
|
86
|
+
if (sub['Status'] === 'Done' || sub['Status'] === newStatus) continue;
|
|
87
|
+
const subUpdates = { status: newStatus };
|
|
88
|
+
// In-Progress keeps existing builder assignments
|
|
89
|
+
if (newStatus !== 'In-Progress' && autoAssign) subUpdates.assigned_to = autoAssign;
|
|
90
|
+
|
|
91
|
+
const sets = Object.keys(subUpdates).map(k => `${k} = ?`).join(', ');
|
|
92
|
+
db.prepare(`UPDATE tasks SET ${sets} WHERE task_id = ?`)
|
|
93
|
+
.run(...Object.values(subUpdates), sub['Task ID']);
|
|
94
|
+
cascaded.push(sub['Task ID']);
|
|
95
|
+
console.log(` ${sub['Task ID']}: ${sub['Status']} → ${newStatus} | Assigned: ${subUpdates.assigned_to || sub['Assigned To']}`);
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
db.exec(TRIGGER_SQL);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const updatedTask = libs.trackerDb.findTask(taskId);
|
|
103
|
+
const cascadedTasks = cascaded.map(id => libs.trackerDb.findTask(id));
|
|
104
|
+
return { taskId, taskRow: updatedTask, cascadedTasks, oldStatus, newStatus };
|
|
105
|
+
},
|
|
106
|
+
async ({ taskRow, cascadedTasks }, db) => {
|
|
107
|
+
const allOps = [];
|
|
108
|
+
const { slackOps } = await syncTaskToSlack(db, taskRow);
|
|
109
|
+
allOps.push(...slackOps);
|
|
110
|
+
for (const sub of cascadedTasks) {
|
|
111
|
+
if (!sub) continue;
|
|
112
|
+
try {
|
|
113
|
+
const { slackOps: subOps } = await syncTaskToSlack(db, sub);
|
|
114
|
+
allOps.push(...subOps);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`WARNING: Slack sync failed for subtask ${sub['Task ID']}: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return allOps;
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Post-commit cleanup (best effort)
|
|
124
|
+
if (newStatus === 'Done') {
|
|
125
|
+
const parsed = task['Spec']?.match(/^(.+?)\s*\(/);
|
|
126
|
+
if (parsed) {
|
|
127
|
+
const specPath = path.resolve(CONFIG.specsDir, 'shared', parsed[1].trim());
|
|
128
|
+
if (fs.existsSync(specPath)) { fs.unlinkSync(specPath); console.error(`Removed local spec: ${parsed[1].trim()}`); }
|
|
129
|
+
}
|
|
130
|
+
// Clean up PR status report
|
|
131
|
+
const prStatusPath = path.resolve(CONFIG.staskHome, 'pr-status', `${taskId}.md`);
|
|
132
|
+
if (fs.existsSync(prStatusPath)) { fs.unlinkSync(prStatusPath); console.error(`Removed PR status: pr-status/${taskId}.md`); }
|
|
133
|
+
if (isParent && task['Worktree'] !== 'None') {
|
|
134
|
+
try {
|
|
135
|
+
execFileSync(process.execPath, [path.join(LIB_DIR, 'worktree-cleanup.mjs'), taskId], {
|
|
136
|
+
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
137
|
+
});
|
|
138
|
+
} catch (err) { console.error(`WARNING: Worktree cleanup failed: ${err.stderr || err.message}`); }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(`${taskId}: "${task['Task Name']}" | ${oldStatus} → ${newStatus} | Assigned: ${autoAssign || task['Assigned To']}`);
|
|
143
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"specsDir": "/path/to/your/specs",
|
|
3
|
+
"projectRepoPath": "/path/to/your/git/repo",
|
|
4
|
+
"worktreeBaseDir": "/path/to/worktrees",
|
|
5
|
+
"staleSessionMinutes": 30,
|
|
6
|
+
"syncIntervalSeconds": 60,
|
|
7
|
+
"maxQaRetries": 3,
|
|
8
|
+
|
|
9
|
+
"human": {
|
|
10
|
+
"name": "YourName",
|
|
11
|
+
"slackUserId": "UXXXXXXXXXX",
|
|
12
|
+
"githubUsername": "your-github-username"
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
"agents": {
|
|
16
|
+
"lead-agent": { "role": "lead", "slackUserId": "UXXXXXXXXXX" },
|
|
17
|
+
"worker-1": { "role": "worker", "slackUserId": "UXXXXXXXXXX" },
|
|
18
|
+
"worker-2": { "role": "worker", "slackUserId": "UXXXXXXXXXX" },
|
|
19
|
+
"qa-agent": { "role": "qa", "slackUserId": "UXXXXXXXXXX" }
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
"slack": {
|
|
23
|
+
"listId": "FROM_ENV",
|
|
24
|
+
"columns": {
|
|
25
|
+
"name": "ColXXXXXXXXX",
|
|
26
|
+
"task_id": "ColXXXXXXXXX",
|
|
27
|
+
"status": "ColXXXXXXXXX",
|
|
28
|
+
"assignee": "ColXX",
|
|
29
|
+
"spec": "ColXXXXXXXXX",
|
|
30
|
+
"type": "ColXXXXXXXXX",
|
|
31
|
+
"worktree": "ColXXXXXXXXX",
|
|
32
|
+
"pr": "ColXXXXXXXXX",
|
|
33
|
+
"qa_report_1": "ColXXXXXXXXX",
|
|
34
|
+
"qa_report_2": "ColXXXXXXXXX",
|
|
35
|
+
"qa_report_3": "ColXXXXXXXXX",
|
|
36
|
+
"completed": "ColXX",
|
|
37
|
+
"spec_approved": "ColXXXXXXXXX",
|
|
38
|
+
"pr_status": "ColXXXXXXXXX"
|
|
39
|
+
},
|
|
40
|
+
"statusOptions": {
|
|
41
|
+
"To-Do": "OptXXXXXXXXX",
|
|
42
|
+
"In-Progress": "OptXXXXXXXXX",
|
|
43
|
+
"Testing": "OptXXXXXXXXX",
|
|
44
|
+
"Ready for Human Review": "OptXXXXXXXXX",
|
|
45
|
+
"Blocked": "OptXXXXXXXXX",
|
|
46
|
+
"Done": "OptXXXXXXXXX"
|
|
47
|
+
},
|
|
48
|
+
"typeOptions": {
|
|
49
|
+
"Feature": "OptXXXXXXXXX",
|
|
50
|
+
"Bug": "OptXXXXXXXXX",
|
|
51
|
+
"Improvement": "OptXXXXXXXXX",
|
|
52
|
+
"Research": "OptXXXXXXXXX"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
package/lib/env.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env.mjs — Loads config.json from ~/.stask/, auto-loads .env,
|
|
3
|
+
* resolves global paths, and imports bundled libs.
|
|
4
|
+
*
|
|
5
|
+
* Global data dir: ~/.stask/ (override with STASK_HOME env var)
|
|
6
|
+
* Contains: config.json, .env, tracker.db, FILE_REGISTRY.json, logs/, pr-status/
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
// ─── STASK_HOME — global data directory ───────────────────────────
|
|
17
|
+
|
|
18
|
+
export const STASK_HOME = process.env.STASK_HOME || path.join(os.homedir(), '.stask');
|
|
19
|
+
|
|
20
|
+
// ─── Load config.json from STASK_HOME ─────────────────────────────
|
|
21
|
+
|
|
22
|
+
let _config = null;
|
|
23
|
+
|
|
24
|
+
export function loadConfig() {
|
|
25
|
+
if (_config) return _config;
|
|
26
|
+
|
|
27
|
+
const configPath = path.join(STASK_HOME, 'config.json');
|
|
28
|
+
if (!fs.existsSync(configPath)) {
|
|
29
|
+
console.error(`ERROR: Config not found at ${configPath}`);
|
|
30
|
+
console.error(`Run: mkdir -p ~/.stask && cp config.example.json ~/.stask/config.json`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
35
|
+
|
|
36
|
+
// staskRoot = package install dir (for finding lib/ scripts)
|
|
37
|
+
const staskRoot = path.resolve(__dirname, '..');
|
|
38
|
+
|
|
39
|
+
_config = {
|
|
40
|
+
...raw,
|
|
41
|
+
// Resolved absolute paths — all runtime data lives in STASK_HOME
|
|
42
|
+
staskRoot,
|
|
43
|
+
staskHome: STASK_HOME,
|
|
44
|
+
dbPath: path.join(STASK_HOME, 'tracker.db'),
|
|
45
|
+
envFile: path.join(STASK_HOME, '.env'),
|
|
46
|
+
registryPath: path.join(STASK_HOME, 'FILE_REGISTRY.json'),
|
|
47
|
+
// Backward compat: expose specsDir as workspace too
|
|
48
|
+
workspace: raw.specsDir,
|
|
49
|
+
};
|
|
50
|
+
return _config;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Auto-load .env into process.env ───────────────────────────────
|
|
54
|
+
|
|
55
|
+
let _envLoaded = false;
|
|
56
|
+
|
|
57
|
+
export function loadEnv() {
|
|
58
|
+
if (_envLoaded) return;
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
try {
|
|
61
|
+
const lines = fs.readFileSync(config.envFile, 'utf-8').split('\n');
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const match = line.match(/^([^#=]+)=(.*)$/);
|
|
64
|
+
if (match) {
|
|
65
|
+
const key = match[1].trim();
|
|
66
|
+
const val = match[2].trim();
|
|
67
|
+
if (!process.env[key]) process.env[key] = val;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(`WARNING: Could not load env file ${config.envFile}: ${err.message}`);
|
|
72
|
+
}
|
|
73
|
+
_envLoaded = true;
|
|
74
|
+
|
|
75
|
+
// Override listId from env if config says FROM_ENV
|
|
76
|
+
if (config.slack?.listId === 'FROM_ENV' && process.env.LIST_ID) {
|
|
77
|
+
config.slack.listId = process.env.LIST_ID;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Local lib imports (all bundled in stask/lib/) ─────────────────
|
|
82
|
+
|
|
83
|
+
import * as _slackApi from './slack-api.mjs';
|
|
84
|
+
import * as _fileUploader from './file-uploader.mjs';
|
|
85
|
+
import * as _trackerDb from './tracker-db.mjs';
|
|
86
|
+
import * as _validate from './validate.mjs';
|
|
87
|
+
|
|
88
|
+
const _workspaceLibs = {
|
|
89
|
+
slackApi: _slackApi,
|
|
90
|
+
fileUploader: _fileUploader,
|
|
91
|
+
trackerDb: _trackerDb,
|
|
92
|
+
validate: _validate,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export async function getWorkspaceLibs() {
|
|
96
|
+
return _workspaceLibs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Pipeline config (stale thresholds, etc.) ─────────────────────
|
|
100
|
+
|
|
101
|
+
let _pipelineConfig = null;
|
|
102
|
+
|
|
103
|
+
export function getPipelineConfig() {
|
|
104
|
+
if (_pipelineConfig) return _pipelineConfig;
|
|
105
|
+
const config = loadConfig();
|
|
106
|
+
_pipelineConfig = {
|
|
107
|
+
staleSessionMinutes: config.staleSessionMinutes || 30,
|
|
108
|
+
maxRetries: config.maxQaRetries || 3,
|
|
109
|
+
};
|
|
110
|
+
return _pipelineConfig;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Convenience exports ───────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
export const CONFIG = loadConfig();
|
|
116
|
+
|
|
117
|
+
// Absolute path to stask/lib/ — used by execFileSync callers
|
|
118
|
+
export const LIB_DIR = __dirname;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-uploader.mjs — Scans workspace files, uploads new/changed ones to Slack,
|
|
3
|
+
* and maintains FILE_REGISTRY.json for agent file references.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import { uploadFile, logger } from './slack-api.mjs';
|
|
10
|
+
|
|
11
|
+
const config = {
|
|
12
|
+
maxFileSizeBytes: 102400,
|
|
13
|
+
dryRun: process.argv.includes('--dry-run'),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const UPLOAD_GLOBS = [
|
|
17
|
+
'shared/specs/*.md',
|
|
18
|
+
'shared/artifacts/*.md',
|
|
19
|
+
'shared/qa-reports/*.md',
|
|
20
|
+
'shared/qa-reports/screenshots/*.png',
|
|
21
|
+
'shared/pr-status/*.md',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Simple glob matching (supports * and **).
|
|
26
|
+
*/
|
|
27
|
+
function matchGlob(filePath, pattern) {
|
|
28
|
+
const regex = pattern
|
|
29
|
+
.replace(/\./g, '\\.')
|
|
30
|
+
.replace(/\*\*\//g, '(.+/)?')
|
|
31
|
+
.replace(/\*/g, '[^/]+');
|
|
32
|
+
return new RegExp(`^${regex}$`).test(filePath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Recursively walk a directory and return all file paths relative to base.
|
|
37
|
+
*/
|
|
38
|
+
function walkDir(dir, base = dir) {
|
|
39
|
+
const results = [];
|
|
40
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
41
|
+
const full = path.join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
if (['.git', 'node_modules', 'sessions', 'skills', '.web42'].includes(entry.name)) continue;
|
|
44
|
+
results.push(...walkDir(full, base));
|
|
45
|
+
} else {
|
|
46
|
+
results.push(path.relative(base, full));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scan workspace for files matching UPLOAD_GLOBS.
|
|
54
|
+
* Returns array of relative paths.
|
|
55
|
+
*/
|
|
56
|
+
export function scanWorkspace(basePath) {
|
|
57
|
+
const allFiles = walkDir(basePath);
|
|
58
|
+
const matched = [];
|
|
59
|
+
for (const file of allFiles) {
|
|
60
|
+
for (const glob of UPLOAD_GLOBS) {
|
|
61
|
+
if (matchGlob(file, glob)) {
|
|
62
|
+
matched.push(file);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return matched.sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load registry from disk. Returns { version, updatedAt, files: {} }.
|
|
72
|
+
*/
|
|
73
|
+
export function loadRegistry(registryPath) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
|
|
76
|
+
} catch {
|
|
77
|
+
return { version: 1, updatedAt: null, files: {} };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Save registry to disk.
|
|
83
|
+
*/
|
|
84
|
+
export function saveRegistry(registryPath, registry) {
|
|
85
|
+
registry.updatedAt = new Date().toISOString();
|
|
86
|
+
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Hash file content (SHA-256, first 16 hex chars).
|
|
91
|
+
*/
|
|
92
|
+
function hashContent(content) {
|
|
93
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sync workspace files to Slack.
|
|
98
|
+
* Uploads new/changed files, skips unchanged ones (content-hash dedup).
|
|
99
|
+
* Returns updated registry.
|
|
100
|
+
*/
|
|
101
|
+
export async function syncFiles(basePath, registry) {
|
|
102
|
+
const files = scanWorkspace(basePath);
|
|
103
|
+
logger.info(`Scanned workspace: ${files.length} files match upload globs`);
|
|
104
|
+
|
|
105
|
+
let uploaded = 0;
|
|
106
|
+
let skipped = 0;
|
|
107
|
+
let errors = 0;
|
|
108
|
+
|
|
109
|
+
for (const relPath of files) {
|
|
110
|
+
const fullPath = path.join(basePath, relPath);
|
|
111
|
+
let content;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const stat = fs.statSync(fullPath);
|
|
115
|
+
if (stat.size > config.maxFileSizeBytes) {
|
|
116
|
+
logger.debug(`Skipping ${relPath} (${stat.size} bytes > ${config.maxFileSizeBytes} limit)`);
|
|
117
|
+
skipped++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
121
|
+
} catch (err) {
|
|
122
|
+
logger.warn(`Cannot read ${relPath}: ${err.message}`);
|
|
123
|
+
errors++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hash = hashContent(content);
|
|
128
|
+
const existing = registry.files[relPath];
|
|
129
|
+
|
|
130
|
+
if (existing && existing.hash === hash) {
|
|
131
|
+
skipped++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (config.dryRun) {
|
|
136
|
+
logger.info(`DRY RUN: Would upload ${relPath} (${content.length} chars, hash ${hash})`);
|
|
137
|
+
uploaded++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const filename = path.basename(relPath);
|
|
143
|
+
const fileId = await uploadFile(filename, content);
|
|
144
|
+
registry.files[relPath] = {
|
|
145
|
+
fileId,
|
|
146
|
+
hash,
|
|
147
|
+
title: filename,
|
|
148
|
+
uploadedAt: new Date().toISOString(),
|
|
149
|
+
sizeBytes: Buffer.byteLength(content, 'utf-8'),
|
|
150
|
+
};
|
|
151
|
+
uploaded++;
|
|
152
|
+
logger.debug(`Uploaded ${relPath} → ${fileId} (${content.length} chars)`);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
logger.error(`Failed to upload ${relPath}: ${err.message}`);
|
|
155
|
+
errors++;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
logger.info(`File sync: ${uploaded} uploaded, ${skipped} unchanged, ${errors} errors`);
|
|
160
|
+
return registry;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Look up a Slack file ID from the registry by relative path.
|
|
165
|
+
* Handles paths with or without backticks, leading shared/, etc.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveFileId(registry, specValue) {
|
|
168
|
+
if (!specValue || specValue === 'N/A') return null;
|
|
169
|
+
let p = specValue.trim().replace(/^`|`$/g, '');
|
|
170
|
+
// Try exact match
|
|
171
|
+
if (registry.files[p]) return registry.files[p].fileId;
|
|
172
|
+
// Try with shared/ prefix
|
|
173
|
+
if (registry.files['shared/' + p]) return registry.files['shared/' + p].fileId;
|
|
174
|
+
// Try prepending specs/
|
|
175
|
+
if (registry.files['shared/specs/' + path.basename(p)]) {
|
|
176
|
+
return registry.files['shared/specs/' + path.basename(p)].fileId;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|