prjct-cli 1.21.0 → 1.22.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/CHANGELOG.md +97 -0
- package/README.md +41 -0
- package/core/__tests__/storage/state-storage-feedback.test.ts +463 -0
- package/core/__tests__/storage/state-storage-history.test.ts +469 -0
- package/core/commands/workflow.ts +5 -2
- package/core/schemas/state.ts +43 -0
- package/core/services/agent-generator.ts +70 -1
- package/core/services/sync-service.ts +115 -4
- package/core/storage/state-storage.ts +190 -3
- package/dist/bin/prjct.mjs +256 -10
- package/package.json +1 -1
|
@@ -77,6 +77,12 @@ class SyncService {
|
|
|
77
77
|
private projectId: string | null = null
|
|
78
78
|
private globalPath: string = ''
|
|
79
79
|
private cliVersion: string = '0.0.0'
|
|
80
|
+
/** Task feedback context for agent generation (PRJ-272) */
|
|
81
|
+
private taskFeedbackContext?: {
|
|
82
|
+
patternsDiscovered: string[]
|
|
83
|
+
knownGotchas: string[]
|
|
84
|
+
agentAccuracy: Array<{ agent: string; rating: string; note?: string }>
|
|
85
|
+
}
|
|
80
86
|
|
|
81
87
|
constructor() {
|
|
82
88
|
this.projectPath = process.cwd()
|
|
@@ -246,9 +252,32 @@ class SyncService {
|
|
|
246
252
|
}
|
|
247
253
|
|
|
248
254
|
// 4. Generate all files (depends on gathered data)
|
|
255
|
+
// Load task feedback for agent generation (PRJ-272)
|
|
256
|
+
let taskFeedbackContext:
|
|
257
|
+
| {
|
|
258
|
+
patternsDiscovered: string[]
|
|
259
|
+
knownGotchas: string[]
|
|
260
|
+
agentAccuracy: Array<{ agent: string; rating: string; note?: string }>
|
|
261
|
+
}
|
|
262
|
+
| undefined
|
|
263
|
+
if (shouldRegenerateAgents) {
|
|
264
|
+
try {
|
|
265
|
+
const feedback = await stateStorage.getAggregatedFeedback(this.projectId!)
|
|
266
|
+
if (
|
|
267
|
+
feedback.patternsDiscovered.length > 0 ||
|
|
268
|
+
feedback.knownGotchas.length > 0 ||
|
|
269
|
+
feedback.agentAccuracy.length > 0
|
|
270
|
+
) {
|
|
271
|
+
taskFeedbackContext = feedback
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
// Feedback loading failure should not block agent generation
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
249
278
|
// Skip agent regeneration if nothing structural changed
|
|
250
279
|
const agents = shouldRegenerateAgents
|
|
251
|
-
? await this.generateAgents(stack, stats)
|
|
280
|
+
? await this.generateAgents(stack, stats, taskFeedbackContext)
|
|
252
281
|
: await this.loadExistingAgents()
|
|
253
282
|
const skills = this.configureSkills(agents)
|
|
254
283
|
const skillsInstalled = shouldRegenerateAgents ? await this.autoInstallSkills(agents) : []
|
|
@@ -658,8 +687,14 @@ class SyncService {
|
|
|
658
687
|
|
|
659
688
|
private async generateAgents(
|
|
660
689
|
stack: StackDetection,
|
|
661
|
-
stats: ProjectStats
|
|
690
|
+
stats: ProjectStats,
|
|
691
|
+
feedbackContext?: {
|
|
692
|
+
patternsDiscovered: string[]
|
|
693
|
+
knownGotchas: string[]
|
|
694
|
+
agentAccuracy: Array<{ agent: string; rating: string; note?: string }>
|
|
695
|
+
}
|
|
662
696
|
): Promise<SyncAgentInfo[]> {
|
|
697
|
+
this.taskFeedbackContext = feedbackContext
|
|
663
698
|
const agents: SyncAgentInfo[] = []
|
|
664
699
|
const agentsPath = path.join(this.globalPath, 'agents')
|
|
665
700
|
|
|
@@ -837,9 +872,58 @@ class SyncService {
|
|
|
837
872
|
content = this.generateMinimalDomainAgent(name, stats, stack)
|
|
838
873
|
}
|
|
839
874
|
|
|
875
|
+
// Inject task feedback learnings (PRJ-272)
|
|
876
|
+
content = this.injectFeedbackSection(content, name)
|
|
877
|
+
|
|
840
878
|
await fs.writeFile(path.join(agentsPath, `${name}.md`), content, 'utf-8')
|
|
841
879
|
}
|
|
842
880
|
|
|
881
|
+
/**
|
|
882
|
+
* Inject a "Recent Learnings" section into agent content from task feedback (PRJ-272)
|
|
883
|
+
*/
|
|
884
|
+
private injectFeedbackSection(content: string, agentName: string): string {
|
|
885
|
+
if (!this.taskFeedbackContext) return content
|
|
886
|
+
|
|
887
|
+
const { patternsDiscovered, knownGotchas, agentAccuracy } = this.taskFeedbackContext
|
|
888
|
+
|
|
889
|
+
const agentNotes = agentAccuracy.filter(
|
|
890
|
+
(a) => a.agent === `${agentName}.md` || a.agent === agentName
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
const hasContent =
|
|
894
|
+
patternsDiscovered.length > 0 || knownGotchas.length > 0 || agentNotes.length > 0
|
|
895
|
+
if (!hasContent) return content
|
|
896
|
+
|
|
897
|
+
const lines: string[] = ['\n## Recent Learnings (from completed tasks)\n']
|
|
898
|
+
|
|
899
|
+
if (patternsDiscovered.length > 0) {
|
|
900
|
+
lines.push('### Discovered Patterns')
|
|
901
|
+
for (const pattern of patternsDiscovered) {
|
|
902
|
+
lines.push(`- ${pattern}`)
|
|
903
|
+
}
|
|
904
|
+
lines.push('')
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (knownGotchas.length > 0) {
|
|
908
|
+
lines.push('### Known Gotchas')
|
|
909
|
+
for (const gotcha of knownGotchas) {
|
|
910
|
+
lines.push(`- ${gotcha}`)
|
|
911
|
+
}
|
|
912
|
+
lines.push('')
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (agentNotes.length > 0) {
|
|
916
|
+
lines.push('### Agent Accuracy Notes')
|
|
917
|
+
for (const note of agentNotes) {
|
|
918
|
+
const desc = note.note ? ` — ${note.note}` : ''
|
|
919
|
+
lines.push(`- ${note.rating}${desc}`)
|
|
920
|
+
}
|
|
921
|
+
lines.push('')
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return content + lines.join('\n')
|
|
925
|
+
}
|
|
926
|
+
|
|
843
927
|
private generateMinimalWorkflowAgent(name: string): string {
|
|
844
928
|
const descriptions: Record<string, string> = {
|
|
845
929
|
'prjct-workflow': 'Task lifecycle: now, done, pause, resume',
|
|
@@ -1253,6 +1337,7 @@ You are the ${name} expert for this project. Apply best practices for the detect
|
|
|
1253
1337
|
/**
|
|
1254
1338
|
* Save sync results as a draft analysis.
|
|
1255
1339
|
* Preserves existing sealed analysis — only the draft is overwritten.
|
|
1340
|
+
* Incorporates task feedback from completed tasks (PRJ-272).
|
|
1256
1341
|
*/
|
|
1257
1342
|
private async saveDraftAnalysis(
|
|
1258
1343
|
git: GitData,
|
|
@@ -1262,14 +1347,40 @@ You are the ${name} expert for this project. Apply best practices for the detect
|
|
|
1262
1347
|
try {
|
|
1263
1348
|
const commitHash = git.recentCommits[0]?.hash || null
|
|
1264
1349
|
|
|
1350
|
+
// Load aggregated feedback from completed tasks (PRJ-272)
|
|
1351
|
+
let patterns: Array<{ name: string; description: string; location?: string }> = []
|
|
1352
|
+
let antiPatterns: Array<{ issue: string; file: string; suggestion: string }> = []
|
|
1353
|
+
try {
|
|
1354
|
+
const feedback = await stateStorage.getAggregatedFeedback(this.projectId!)
|
|
1355
|
+
|
|
1356
|
+
// Convert discovered patterns to CodePattern objects
|
|
1357
|
+
if (feedback.patternsDiscovered.length > 0) {
|
|
1358
|
+
patterns = feedback.patternsDiscovered.map((p) => ({
|
|
1359
|
+
name: p,
|
|
1360
|
+
description: `Discovered during task execution: ${p}`,
|
|
1361
|
+
}))
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Convert known gotchas (recurring issues) to AntiPattern objects
|
|
1365
|
+
if (feedback.knownGotchas.length > 0) {
|
|
1366
|
+
antiPatterns = feedback.knownGotchas.map((g) => ({
|
|
1367
|
+
issue: g,
|
|
1368
|
+
file: 'multiple',
|
|
1369
|
+
suggestion: `Recurring issue reported across tasks: ${g}`,
|
|
1370
|
+
}))
|
|
1371
|
+
}
|
|
1372
|
+
} catch {
|
|
1373
|
+
// Feedback aggregation failure should not block analysis
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1265
1376
|
await analysisStorage.saveDraft(this.projectId!, {
|
|
1266
1377
|
projectId: this.projectId!,
|
|
1267
1378
|
languages: stats.languages,
|
|
1268
1379
|
frameworks: stats.frameworks,
|
|
1269
1380
|
configFiles: [],
|
|
1270
1381
|
fileCount: stats.fileCount,
|
|
1271
|
-
patterns
|
|
1272
|
-
antiPatterns
|
|
1382
|
+
patterns,
|
|
1383
|
+
antiPatterns,
|
|
1273
1384
|
analyzedAt: dateHelper.getTimestamp(),
|
|
1274
1385
|
status: 'draft',
|
|
1275
1386
|
commitHash: commitHash ?? undefined,
|
|
@@ -15,6 +15,8 @@ import type {
|
|
|
15
15
|
StateJson,
|
|
16
16
|
Subtask,
|
|
17
17
|
SubtaskCompletionData,
|
|
18
|
+
TaskFeedback,
|
|
19
|
+
TaskHistoryEntry,
|
|
18
20
|
} from '../schemas/state'
|
|
19
21
|
import { StateJsonSchema, SubtaskCompletionDataSchema } from '../schemas/state'
|
|
20
22
|
import { getTimestamp, toRelative } from '../utils/date-helper'
|
|
@@ -33,6 +35,8 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
33
35
|
return {
|
|
34
36
|
currentTask: null,
|
|
35
37
|
previousTask: null,
|
|
38
|
+
pausedTasks: [],
|
|
39
|
+
taskHistory: [],
|
|
36
40
|
lastUpdated: '',
|
|
37
41
|
}
|
|
38
42
|
}
|
|
@@ -140,6 +144,39 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
140
144
|
})
|
|
141
145
|
m.blank().italic('Use /p:resume to continue')
|
|
142
146
|
})
|
|
147
|
+
.when((data.taskHistory?.length || 0) > 0, (m) => {
|
|
148
|
+
const history = this.getTaskHistoryFromState(data)
|
|
149
|
+
if (history.length === 0) return
|
|
150
|
+
|
|
151
|
+
// Filter by current task classification if available
|
|
152
|
+
const currentTaskType = (data.currentTask as any)?.type
|
|
153
|
+
const relevantHistory = currentTaskType
|
|
154
|
+
? history.filter((h) => h.classification === currentTaskType).slice(0, 3)
|
|
155
|
+
: history.slice(0, 5)
|
|
156
|
+
|
|
157
|
+
if (relevantHistory.length === 0) return
|
|
158
|
+
|
|
159
|
+
m.hr().h2(
|
|
160
|
+
currentTaskType
|
|
161
|
+
? `Recent ${currentTaskType} tasks (${relevantHistory.length})`
|
|
162
|
+
: `Recent tasks (${relevantHistory.length})`
|
|
163
|
+
)
|
|
164
|
+
relevantHistory.forEach((entry, i) => {
|
|
165
|
+
m.raw(`${i + 1}. **${entry.title}** (${entry.classification})`)
|
|
166
|
+
.raw(
|
|
167
|
+
` Completed: ${toRelative(entry.completedAt)} | ${entry.subtaskCount} subtask${entry.subtaskCount > 1 ? 's' : ''}`
|
|
168
|
+
)
|
|
169
|
+
.raw(` Outcome: ${entry.outcome}`)
|
|
170
|
+
if (entry.linearId) m.raw(` Linear: ${entry.linearId}`)
|
|
171
|
+
if (entry.feedback?.patternsDiscovered?.length) {
|
|
172
|
+
m.raw(` Patterns: ${entry.feedback.patternsDiscovered.join(', ')}`)
|
|
173
|
+
}
|
|
174
|
+
if (entry.feedback?.issuesEncountered?.length) {
|
|
175
|
+
m.raw(` Gotchas: ${entry.feedback.issuesEncountered.join(', ')}`)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
m.blank().italic('Task history helps identify patterns and improve decisions')
|
|
179
|
+
})
|
|
143
180
|
.blank()
|
|
144
181
|
.build()
|
|
145
182
|
}
|
|
@@ -220,8 +257,10 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
220
257
|
|
|
221
258
|
/**
|
|
222
259
|
* Complete current task
|
|
260
|
+
* Creates a TaskHistoryEntry and adds it to taskHistory with FIFO eviction
|
|
261
|
+
* Optionally accepts structured feedback for the task-to-analysis feedback loop (PRJ-272)
|
|
223
262
|
*/
|
|
224
|
-
async completeTask(projectId: string): Promise<CurrentTask | null> {
|
|
263
|
+
async completeTask(projectId: string, feedback?: TaskFeedback): Promise<CurrentTask | null> {
|
|
225
264
|
const state = await this.read(projectId)
|
|
226
265
|
const completedTask = state.currentTask
|
|
227
266
|
|
|
@@ -231,10 +270,22 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
231
270
|
|
|
232
271
|
this.validateTransition(state, 'done')
|
|
233
272
|
|
|
273
|
+
const completedAt = getTimestamp()
|
|
274
|
+
|
|
275
|
+
// Create task history entry for completed task (with optional feedback)
|
|
276
|
+
const historyEntry = this.createTaskHistoryEntry(completedTask, completedAt, feedback)
|
|
277
|
+
|
|
278
|
+
// Get existing task history with backward compatibility
|
|
279
|
+
const existingHistory = this.getTaskHistoryFromState(state)
|
|
280
|
+
|
|
281
|
+
// Add new entry to beginning, enforce max limit with FIFO eviction
|
|
282
|
+
const taskHistory = [historyEntry, ...existingHistory].slice(0, this.maxTaskHistory)
|
|
283
|
+
|
|
234
284
|
await this.update(projectId, () => ({
|
|
235
285
|
currentTask: null,
|
|
236
286
|
previousTask: null,
|
|
237
|
-
|
|
287
|
+
taskHistory,
|
|
288
|
+
lastUpdated: completedAt,
|
|
238
289
|
}))
|
|
239
290
|
|
|
240
291
|
// Publish incremental event
|
|
@@ -242,15 +293,64 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
242
293
|
taskId: completedTask.id,
|
|
243
294
|
description: completedTask.description,
|
|
244
295
|
startedAt: completedTask.startedAt,
|
|
245
|
-
completedAt
|
|
296
|
+
completedAt,
|
|
246
297
|
})
|
|
247
298
|
|
|
248
299
|
return completedTask
|
|
249
300
|
}
|
|
250
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Create a TaskHistoryEntry from a completed task
|
|
304
|
+
* Optionally includes structured feedback for the feedback loop (PRJ-272)
|
|
305
|
+
*/
|
|
306
|
+
private createTaskHistoryEntry(
|
|
307
|
+
task: CurrentTask,
|
|
308
|
+
completedAt: string,
|
|
309
|
+
feedback?: TaskFeedback
|
|
310
|
+
): TaskHistoryEntry {
|
|
311
|
+
// Extended task properties (may be present in storage but not in schema)
|
|
312
|
+
const taskAny = task as any
|
|
313
|
+
|
|
314
|
+
// Extract subtask summaries (only completed subtasks with summaries)
|
|
315
|
+
const subtaskSummaries = (task.subtasks || [])
|
|
316
|
+
.filter((st) => st.status === 'completed' && st.summary)
|
|
317
|
+
.map((st) => st.summary!)
|
|
318
|
+
|
|
319
|
+
// Calculate outcome description from subtask summaries
|
|
320
|
+
const outcome =
|
|
321
|
+
subtaskSummaries.length > 0
|
|
322
|
+
? subtaskSummaries.map((s) => s.title).join(', ')
|
|
323
|
+
: 'Task completed'
|
|
324
|
+
|
|
325
|
+
const entry: TaskHistoryEntry = {
|
|
326
|
+
taskId: task.id,
|
|
327
|
+
title: taskAny.parentDescription || task.description,
|
|
328
|
+
classification: taskAny.type || 'improvement',
|
|
329
|
+
startedAt: task.startedAt,
|
|
330
|
+
completedAt,
|
|
331
|
+
subtaskCount: task.subtasks?.length || 0,
|
|
332
|
+
subtaskSummaries,
|
|
333
|
+
outcome,
|
|
334
|
+
branchName: taskAny.branch || 'unknown',
|
|
335
|
+
linearId: task.linearId,
|
|
336
|
+
linearUuid: task.linearUuid,
|
|
337
|
+
prUrl: taskAny.prUrl,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Attach feedback if provided (PRJ-272)
|
|
341
|
+
if (feedback) {
|
|
342
|
+
entry.feedback = feedback
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return entry
|
|
346
|
+
}
|
|
347
|
+
|
|
251
348
|
/** Max number of paused tasks (configurable) */
|
|
252
349
|
private maxPausedTasks = 5
|
|
253
350
|
|
|
351
|
+
/** Max number of task history entries (configurable) */
|
|
352
|
+
private maxTaskHistory = 20
|
|
353
|
+
|
|
254
354
|
/** Staleness threshold in days */
|
|
255
355
|
private stalenessThresholdDays = 30
|
|
256
356
|
|
|
@@ -367,6 +467,14 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
367
467
|
return paused
|
|
368
468
|
}
|
|
369
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Get task history from state with backward compatibility
|
|
472
|
+
* Ensures taskHistory is always an array (never undefined)
|
|
473
|
+
*/
|
|
474
|
+
private getTaskHistoryFromState(state: StateJson): TaskHistoryEntry[] {
|
|
475
|
+
return state.taskHistory || []
|
|
476
|
+
}
|
|
477
|
+
|
|
370
478
|
/**
|
|
371
479
|
* Get stale paused tasks (older than threshold)
|
|
372
480
|
*/
|
|
@@ -462,6 +570,85 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
462
570
|
return this.getPausedTasksFromState(state)
|
|
463
571
|
}
|
|
464
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Get full task history (completed tasks)
|
|
575
|
+
*/
|
|
576
|
+
async getTaskHistory(projectId: string): Promise<TaskHistoryEntry[]> {
|
|
577
|
+
const state = await this.read(projectId)
|
|
578
|
+
return this.getTaskHistoryFromState(state)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Get most recent task from history
|
|
583
|
+
*/
|
|
584
|
+
async getMostRecentTask(projectId: string): Promise<TaskHistoryEntry | null> {
|
|
585
|
+
const state = await this.read(projectId)
|
|
586
|
+
const history = this.getTaskHistoryFromState(state)
|
|
587
|
+
return history[0] || null
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Get task history filtered by classification
|
|
592
|
+
*/
|
|
593
|
+
async getTaskHistoryByType(
|
|
594
|
+
projectId: string,
|
|
595
|
+
classification: TaskHistoryEntry['classification']
|
|
596
|
+
): Promise<TaskHistoryEntry[]> {
|
|
597
|
+
const state = await this.read(projectId)
|
|
598
|
+
const history = this.getTaskHistoryFromState(state)
|
|
599
|
+
return history.filter((t) => t.classification === classification)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Aggregate feedback from all task history entries (PRJ-272)
|
|
604
|
+
* Used by sync to feed task discoveries back into analysis and agent generation.
|
|
605
|
+
* Returns consolidated patterns, stack confirmations, issues, and agent accuracy.
|
|
606
|
+
*/
|
|
607
|
+
async getAggregatedFeedback(projectId: string): Promise<{
|
|
608
|
+
stackConfirmed: string[]
|
|
609
|
+
patternsDiscovered: string[]
|
|
610
|
+
agentAccuracy: Array<{ agent: string; rating: string; note?: string }>
|
|
611
|
+
issuesEncountered: string[]
|
|
612
|
+
knownGotchas: string[]
|
|
613
|
+
}> {
|
|
614
|
+
const history = await this.getTaskHistory(projectId)
|
|
615
|
+
const entriesWithFeedback = history.filter((h) => h.feedback)
|
|
616
|
+
|
|
617
|
+
const stackConfirmed: string[] = []
|
|
618
|
+
const patternsDiscovered: string[] = []
|
|
619
|
+
const agentAccuracy: Array<{ agent: string; rating: string; note?: string }> = []
|
|
620
|
+
const allIssues: string[] = []
|
|
621
|
+
|
|
622
|
+
for (const entry of entriesWithFeedback) {
|
|
623
|
+
const fb = entry.feedback!
|
|
624
|
+
if (fb.stackConfirmed) stackConfirmed.push(...fb.stackConfirmed)
|
|
625
|
+
if (fb.patternsDiscovered) patternsDiscovered.push(...fb.patternsDiscovered)
|
|
626
|
+
if (fb.agentAccuracy) agentAccuracy.push(...fb.agentAccuracy)
|
|
627
|
+
if (fb.issuesEncountered) allIssues.push(...fb.issuesEncountered)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Deduplicate patterns and stack confirmations
|
|
631
|
+
const uniqueStack = [...new Set(stackConfirmed)]
|
|
632
|
+
const uniquePatterns = [...new Set(patternsDiscovered)]
|
|
633
|
+
|
|
634
|
+
// Promote recurring issues (2+) to known gotchas
|
|
635
|
+
const issueCounts = new Map<string, number>()
|
|
636
|
+
for (const issue of allIssues) {
|
|
637
|
+
issueCounts.set(issue, (issueCounts.get(issue) || 0) + 1)
|
|
638
|
+
}
|
|
639
|
+
const knownGotchas = [...issueCounts.entries()]
|
|
640
|
+
.filter(([_, count]) => count >= 2)
|
|
641
|
+
.map(([issue]) => issue)
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
stackConfirmed: uniqueStack,
|
|
645
|
+
patternsDiscovered: uniquePatterns,
|
|
646
|
+
agentAccuracy,
|
|
647
|
+
issuesEncountered: [...new Set(allIssues)],
|
|
648
|
+
knownGotchas,
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
465
652
|
// =========== Subtask Methods ===========
|
|
466
653
|
|
|
467
654
|
/**
|