@fermindi/pwn-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/cli/batch.js +333 -0
  4. package/cli/codespaces.js +303 -0
  5. package/cli/index.js +91 -0
  6. package/cli/inject.js +53 -0
  7. package/cli/knowledge.js +531 -0
  8. package/cli/notify.js +135 -0
  9. package/cli/patterns.js +665 -0
  10. package/cli/status.js +91 -0
  11. package/cli/validate.js +61 -0
  12. package/package.json +70 -0
  13. package/src/core/inject.js +128 -0
  14. package/src/core/state.js +91 -0
  15. package/src/core/validate.js +202 -0
  16. package/src/core/workspace.js +176 -0
  17. package/src/index.js +20 -0
  18. package/src/knowledge/gc.js +308 -0
  19. package/src/knowledge/lifecycle.js +401 -0
  20. package/src/knowledge/promote.js +364 -0
  21. package/src/knowledge/references.js +342 -0
  22. package/src/patterns/matcher.js +218 -0
  23. package/src/patterns/registry.js +375 -0
  24. package/src/patterns/triggers.js +423 -0
  25. package/src/services/batch-service.js +849 -0
  26. package/src/services/notification-service.js +342 -0
  27. package/templates/codespaces/devcontainer.json +52 -0
  28. package/templates/codespaces/setup.sh +70 -0
  29. package/templates/workspace/.ai/README.md +164 -0
  30. package/templates/workspace/.ai/agents/README.md +204 -0
  31. package/templates/workspace/.ai/agents/claude.md +625 -0
  32. package/templates/workspace/.ai/config/.gitkeep +0 -0
  33. package/templates/workspace/.ai/config/README.md +79 -0
  34. package/templates/workspace/.ai/config/notifications.template.json +20 -0
  35. package/templates/workspace/.ai/memory/deadends.md +79 -0
  36. package/templates/workspace/.ai/memory/decisions.md +58 -0
  37. package/templates/workspace/.ai/memory/patterns.md +65 -0
  38. package/templates/workspace/.ai/patterns/backend/README.md +126 -0
  39. package/templates/workspace/.ai/patterns/frontend/README.md +103 -0
  40. package/templates/workspace/.ai/patterns/index.md +256 -0
  41. package/templates/workspace/.ai/patterns/triggers.json +1087 -0
  42. package/templates/workspace/.ai/patterns/universal/README.md +141 -0
  43. package/templates/workspace/.ai/state.template.json +8 -0
  44. package/templates/workspace/.ai/tasks/active.md +77 -0
  45. package/templates/workspace/.ai/tasks/backlog.md +95 -0
  46. package/templates/workspace/.ai/workflows/batch-task.md +356 -0
@@ -0,0 +1,176 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { getState } from './state.js';
4
+
5
+ /**
6
+ * Get comprehensive workspace information
7
+ * @param {string} cwd - Working directory
8
+ * @returns {object} Workspace info
9
+ */
10
+ export function getWorkspaceInfo(cwd = process.cwd()) {
11
+ const aiDir = join(cwd, '.ai');
12
+
13
+ if (!existsSync(aiDir)) {
14
+ return {
15
+ exists: false,
16
+ path: aiDir
17
+ };
18
+ }
19
+
20
+ const state = getState(cwd);
21
+ const tasks = getTasksSummary(cwd);
22
+ const memory = getMemorySummary(cwd);
23
+ const patterns = getPatternsSummary(cwd);
24
+
25
+ return {
26
+ exists: true,
27
+ path: aiDir,
28
+ state,
29
+ tasks,
30
+ memory,
31
+ patterns
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Get summary of active tasks
37
+ * @param {string} cwd - Working directory
38
+ * @returns {object} Tasks summary
39
+ */
40
+ function getTasksSummary(cwd) {
41
+ const activePath = join(cwd, '.ai', 'tasks', 'active.md');
42
+ const backlogPath = join(cwd, '.ai', 'tasks', 'backlog.md');
43
+
44
+ const summary = {
45
+ active: { total: 0, completed: 0, pending: 0 },
46
+ backlog: { total: 0 }
47
+ };
48
+
49
+ // Parse active.md
50
+ if (existsSync(activePath)) {
51
+ const content = readFileSync(activePath, 'utf8');
52
+ const lines = content.split('\n');
53
+
54
+ for (const line of lines) {
55
+ if (line.match(/^- \[[ x]\]/)) {
56
+ summary.active.total++;
57
+ if (line.match(/^- \[x\]/i)) {
58
+ summary.active.completed++;
59
+ } else {
60
+ summary.active.pending++;
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ // Parse backlog.md
67
+ if (existsSync(backlogPath)) {
68
+ const content = readFileSync(backlogPath, 'utf8');
69
+ const lines = content.split('\n');
70
+
71
+ for (const line of lines) {
72
+ if (line.match(/^- \[[ ]\]/) || line.match(/^\d+\./)) {
73
+ summary.backlog.total++;
74
+ }
75
+ }
76
+ }
77
+
78
+ return summary;
79
+ }
80
+
81
+ /**
82
+ * Get summary of memory files
83
+ * @param {string} cwd - Working directory
84
+ * @returns {object} Memory summary
85
+ */
86
+ function getMemorySummary(cwd) {
87
+ const memoryDir = join(cwd, '.ai', 'memory');
88
+
89
+ const summary = {
90
+ decisions: 0,
91
+ patterns: 0,
92
+ deadends: 0
93
+ };
94
+
95
+ // Count decisions (DEC-XXX format)
96
+ const decisionsPath = join(memoryDir, 'decisions.md');
97
+ if (existsSync(decisionsPath)) {
98
+ const content = readFileSync(decisionsPath, 'utf8');
99
+ const matches = content.match(/DEC-\d+/g);
100
+ summary.decisions = matches ? matches.length : 0;
101
+ }
102
+
103
+ // Count patterns (### headers in patterns.md)
104
+ const patternsPath = join(memoryDir, 'patterns.md');
105
+ if (existsSync(patternsPath)) {
106
+ const content = readFileSync(patternsPath, 'utf8');
107
+ const matches = content.match(/^### /gm);
108
+ summary.patterns = matches ? matches.length : 0;
109
+ }
110
+
111
+ // Count dead-ends (DE-XXX format)
112
+ const deadendsPath = join(memoryDir, 'deadends.md');
113
+ if (existsSync(deadendsPath)) {
114
+ const content = readFileSync(deadendsPath, 'utf8');
115
+ const matches = content.match(/DE-\d+/g);
116
+ summary.deadends = matches ? matches.length : 0;
117
+ }
118
+
119
+ return summary;
120
+ }
121
+
122
+ /**
123
+ * Get summary of pattern triggers
124
+ * @param {string} cwd - Working directory
125
+ * @returns {object} Patterns summary
126
+ */
127
+ function getPatternsSummary(cwd) {
128
+ const patternsDir = join(cwd, '.ai', 'patterns');
129
+
130
+ const summary = {
131
+ triggers: 0,
132
+ categories: []
133
+ };
134
+
135
+ // Count triggers in index.md
136
+ const indexPath = join(patternsDir, 'index.md');
137
+ if (existsSync(indexPath)) {
138
+ const content = readFileSync(indexPath, 'utf8');
139
+ // Count lines that look like trigger mappings (| pattern | directory |)
140
+ const matches = content.match(/^\|[^|]+\|[^|]+\|$/gm);
141
+ // Subtract header rows (usually 2)
142
+ summary.triggers = matches ? Math.max(0, matches.length - 2) : 0;
143
+ }
144
+
145
+ // List pattern categories (subdirectories)
146
+ if (existsSync(patternsDir)) {
147
+ const entries = readdirSync(patternsDir);
148
+ for (const entry of entries) {
149
+ const entryPath = join(patternsDir, entry);
150
+ if (statSync(entryPath).isDirectory()) {
151
+ summary.categories.push(entry);
152
+ }
153
+ }
154
+ }
155
+
156
+ return summary;
157
+ }
158
+
159
+ /**
160
+ * Check if current directory is inside a PWN workspace
161
+ * @param {string} cwd - Starting directory
162
+ * @returns {string|null} Path to .ai directory or null
163
+ */
164
+ export function findWorkspaceRoot(cwd = process.cwd()) {
165
+ let current = cwd;
166
+
167
+ while (current !== join(current, '..')) {
168
+ const aiDir = join(current, '.ai');
169
+ if (existsSync(aiDir)) {
170
+ return current;
171
+ }
172
+ current = join(current, '..');
173
+ }
174
+
175
+ return null;
176
+ }
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // PWN Core - Main exports
2
+ export { inject } from './core/inject.js';
3
+ export { getState, updateState, initState } from './core/state.js';
4
+ export { validate } from './core/validate.js';
5
+ export { getWorkspaceInfo } from './core/workspace.js';
6
+
7
+ // Services
8
+ export * as notifications from './services/notification-service.js';
9
+ export * as batch from './services/batch-service.js';
10
+
11
+ // Patterns
12
+ export * as patterns from './patterns/registry.js';
13
+ export * as triggers from './patterns/triggers.js';
14
+ export * as matcher from './patterns/matcher.js';
15
+
16
+ // Knowledge Lifecycle
17
+ export * as lifecycle from './knowledge/lifecycle.js';
18
+ export * as references from './knowledge/references.js';
19
+ export * as gc from './knowledge/gc.js';
20
+ export * as promote from './knowledge/promote.js';
@@ -0,0 +1,308 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ getKnowledgeRegistry,
5
+ saveKnowledgeRegistry,
6
+ getAllKnowledge,
7
+ changeStatus
8
+ } from './lifecycle.js';
9
+ import { findBrokenReferences } from './references.js';
10
+
11
+ /**
12
+ * GC Result
13
+ * @typedef {Object} GCResult
14
+ * @property {string[]} marked - Items marked for deletion
15
+ * @property {string[]} archived - Items archived
16
+ * @property {string[]} deleted - Items removed
17
+ * @property {string[]} cleaned - Broken references cleaned
18
+ */
19
+
20
+ /**
21
+ * Identify items that should be garbage collected
22
+ * Criteria:
23
+ * - No related files exist
24
+ * - No references from other decisions
25
+ * - Status is 'garbage' or orphaned
26
+ * @param {string} cwd - Working directory
27
+ * @returns {Object[]} Items to collect
28
+ */
29
+ export function identifyGarbage(cwd = process.cwd()) {
30
+ const registry = getKnowledgeRegistry(cwd);
31
+ if (!registry) {
32
+ return [];
33
+ }
34
+
35
+ const garbage = [];
36
+
37
+ for (const [id, item] of Object.entries(registry.items)) {
38
+ // Already marked as garbage
39
+ if (item.status === 'garbage') {
40
+ garbage.push({
41
+ id,
42
+ title: item.title,
43
+ reason: 'marked_garbage',
44
+ relatedFiles: item.relatedFiles || [],
45
+ relatedDecisions: item.relatedDecisions || []
46
+ });
47
+ continue;
48
+ }
49
+
50
+ // Check if orphaned (no references)
51
+ const hasFileRefs = (item.relatedFiles || []).length > 0;
52
+ const hasDecisionRefs = (item.relatedDecisions || []).length > 0;
53
+ const hasTaskRefs = (item.relatedTasks || []).length > 0;
54
+
55
+ if (!hasFileRefs && !hasDecisionRefs && !hasTaskRefs) {
56
+ // Check if it's been accessed recently (last 30 days)
57
+ const lastAccessed = item.lastAccessed ? new Date(item.lastAccessed) : null;
58
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
59
+
60
+ if (!lastAccessed || lastAccessed < thirtyDaysAgo) {
61
+ garbage.push({
62
+ id,
63
+ title: item.title,
64
+ reason: 'orphaned',
65
+ lastAccessed: item.lastAccessed,
66
+ relatedFiles: [],
67
+ relatedDecisions: []
68
+ });
69
+ }
70
+ }
71
+ }
72
+
73
+ return garbage;
74
+ }
75
+
76
+ /**
77
+ * Run garbage collection in dry-run mode
78
+ * @param {string} cwd - Working directory
79
+ * @returns {GCResult}
80
+ */
81
+ export function dryRunGC(cwd = process.cwd()) {
82
+ const garbage = identifyGarbage(cwd);
83
+ const broken = findBrokenReferences(cwd);
84
+
85
+ return {
86
+ marked: garbage.map(g => g.id),
87
+ archived: [],
88
+ deleted: [],
89
+ cleaned: broken.map(b => b.id),
90
+ details: {
91
+ garbage,
92
+ broken
93
+ }
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Execute garbage collection
99
+ * @param {Object} options - Options
100
+ * @param {boolean} options.archive - Archive instead of delete
101
+ * @param {boolean} options.cleanBroken - Clean broken references
102
+ * @param {string} cwd - Working directory
103
+ * @returns {GCResult}
104
+ */
105
+ export function executeGC(options = {}, cwd = process.cwd()) {
106
+ const { archive = true, cleanBroken = true } = options;
107
+ const registry = getKnowledgeRegistry(cwd);
108
+
109
+ if (!registry) {
110
+ return { marked: [], archived: [], deleted: [], cleaned: [] };
111
+ }
112
+
113
+ const result = {
114
+ marked: [],
115
+ archived: [],
116
+ deleted: [],
117
+ cleaned: []
118
+ };
119
+
120
+ // Identify garbage
121
+ const garbage = identifyGarbage(cwd);
122
+
123
+ for (const item of garbage) {
124
+ if (archive) {
125
+ // Archive to memory/archive
126
+ archiveKnowledgeItem(item.id, cwd);
127
+ // Update local registry reference to reflect archived status
128
+ registry.items[item.id].status = 'archived';
129
+ result.archived.push(item.id);
130
+ } else {
131
+ // Delete from registry
132
+ delete registry.items[item.id];
133
+ result.deleted.push(item.id);
134
+ }
135
+ result.marked.push(item.id);
136
+ }
137
+
138
+ // Clean broken references if requested
139
+ if (cleanBroken) {
140
+ const broken = findBrokenReferences(cwd);
141
+ for (const { id, brokenFiles, brokenDecisions } of broken) {
142
+ const item = registry.items[id];
143
+ if (item) {
144
+ // Remove broken file references
145
+ item.relatedFiles = (item.relatedFiles || []).filter(
146
+ f => !brokenFiles.includes(f)
147
+ );
148
+ // Remove broken decision references
149
+ item.relatedDecisions = (item.relatedDecisions || []).filter(
150
+ d => !brokenDecisions.includes(d)
151
+ );
152
+ result.cleaned.push(id);
153
+ }
154
+ }
155
+ }
156
+
157
+ // Update last GC timestamp
158
+ registry.lastGC = new Date().toISOString();
159
+ saveKnowledgeRegistry(registry, cwd);
160
+
161
+ return result;
162
+ }
163
+
164
+ /**
165
+ * Archive a knowledge item to memory/archive
166
+ * @param {string} id - Item ID
167
+ * @param {string} cwd - Working directory
168
+ */
169
+ function archiveKnowledgeItem(id, cwd = process.cwd()) {
170
+ const registry = getKnowledgeRegistry(cwd);
171
+ if (!registry || !registry.items[id]) return;
172
+
173
+ const item = registry.items[id];
174
+ const archiveDir = join(cwd, '.ai', 'memory', 'archive');
175
+
176
+ // Ensure archive directory exists
177
+ if (!existsSync(archiveDir)) {
178
+ mkdirSync(archiveDir, { recursive: true });
179
+ }
180
+
181
+ // Append to archive file
182
+ const archiveFile = join(archiveDir, 'archived-knowledge.md');
183
+ const archiveEntry = formatArchiveEntry(item);
184
+
185
+ appendFileSync(archiveFile, archiveEntry);
186
+
187
+ // Update status in registry
188
+ registry.items[id].status = 'archived';
189
+ registry.items[id].archivedAt = new Date().toISOString();
190
+ saveKnowledgeRegistry(registry, cwd);
191
+ }
192
+
193
+ /**
194
+ * Format a knowledge item for archive
195
+ * @param {Object} item - Knowledge item
196
+ * @returns {string}
197
+ */
198
+ function formatArchiveEntry(item) {
199
+ const divider = '\n---\n\n';
200
+ const header = `## ${item.id}: ${item.title} [ARCHIVED]\n`;
201
+ const metadata = [
202
+ `**Type:** ${item.type}`,
203
+ `**Created:** ${item.createdAt || 'Unknown'}`,
204
+ `**Archived:** ${new Date().toISOString().split('T')[0]}`,
205
+ `**Access Count:** ${item.accessCount || 0}`,
206
+ `**Applied Count:** ${item.appliedCount || 0}`,
207
+ `**Last Accessed:** ${item.lastAccessed || 'Never'}`
208
+ ].join('\n');
209
+
210
+ const refs = [];
211
+ if (item.relatedFiles?.length) {
212
+ refs.push(`**Related Files:** ${item.relatedFiles.join(', ')}`);
213
+ }
214
+ if (item.relatedDecisions?.length) {
215
+ refs.push(`**Related Decisions:** ${item.relatedDecisions.join(', ')}`);
216
+ }
217
+ if (item.appliedContexts?.length) {
218
+ refs.push(`**Applied Contexts:** ${item.appliedContexts.join(', ')}`);
219
+ }
220
+
221
+ return header + metadata + '\n' + refs.join('\n') + divider;
222
+ }
223
+
224
+ /**
225
+ * Get items that are candidates for archival (inactive but not orphaned)
226
+ * @param {number} inactiveDays - Days of inactivity
227
+ * @param {string} cwd - Working directory
228
+ * @returns {Object[]}
229
+ */
230
+ export function getArchivalCandidates(inactiveDays = 60, cwd = process.cwd()) {
231
+ const registry = getKnowledgeRegistry(cwd);
232
+ if (!registry) return [];
233
+
234
+ const candidates = [];
235
+ const cutoff = new Date(Date.now() - inactiveDays * 24 * 60 * 60 * 1000);
236
+
237
+ for (const [id, item] of Object.entries(registry.items)) {
238
+ if (item.status !== 'active') continue;
239
+
240
+ const lastAccessed = item.lastAccessed ? new Date(item.lastAccessed) : null;
241
+
242
+ // Has references but hasn't been accessed
243
+ const hasRefs = (item.relatedFiles || []).length > 0 ||
244
+ (item.relatedDecisions || []).length > 0;
245
+
246
+ if (hasRefs && (!lastAccessed || lastAccessed < cutoff)) {
247
+ candidates.push({
248
+ id,
249
+ title: item.title,
250
+ lastAccessed: item.lastAccessed,
251
+ daysSinceAccess: lastAccessed
252
+ ? Math.floor((Date.now() - lastAccessed) / (24 * 60 * 60 * 1000))
253
+ : 'never',
254
+ refCount: (item.relatedFiles || []).length + (item.relatedDecisions || []).length
255
+ });
256
+ }
257
+ }
258
+
259
+ return candidates.sort((a, b) => {
260
+ const aDate = a.lastAccessed ? new Date(a.lastAccessed) : new Date(0);
261
+ const bDate = b.lastAccessed ? new Date(b.lastAccessed) : new Date(0);
262
+ return aDate - bDate;
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Restore an archived item back to active
268
+ * @param {string} id - Item ID
269
+ * @param {string} cwd - Working directory
270
+ * @returns {Object|null}
271
+ */
272
+ export function restoreFromArchive(id, cwd = process.cwd()) {
273
+ const registry = getKnowledgeRegistry(cwd);
274
+ if (!registry || !registry.items[id]) return null;
275
+
276
+ const item = registry.items[id];
277
+ if (item.status !== 'archived') {
278
+ return { error: `Item ${id} is not archived (status: ${item.status})` };
279
+ }
280
+
281
+ item.status = 'active';
282
+ item.restoredAt = new Date().toISOString();
283
+ delete item.archivedAt;
284
+
285
+ saveKnowledgeRegistry(registry, cwd);
286
+ return item;
287
+ }
288
+
289
+ /**
290
+ * Get GC statistics
291
+ * @param {string} cwd - Working directory
292
+ * @returns {Object}
293
+ */
294
+ export function getGCStats(cwd = process.cwd()) {
295
+ const registry = getKnowledgeRegistry(cwd);
296
+ const garbage = identifyGarbage(cwd);
297
+ const archivalCandidates = getArchivalCandidates(60, cwd);
298
+ const broken = findBrokenReferences(cwd);
299
+
300
+ return {
301
+ lastGC: registry?.lastGC || 'Never',
302
+ garbageCount: garbage.length,
303
+ archivalCandidates: archivalCandidates.length,
304
+ brokenReferences: broken.length,
305
+ garbage: garbage.map(g => ({ id: g.id, reason: g.reason })),
306
+ candidates: archivalCandidates.slice(0, 5)
307
+ };
308
+ }