@magnusekdahl/parallix 1.0.0 → 1.0.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 +44 -24
- package/docs/authority-reference.md +29 -2
- package/lib/agents/opencode.js +117 -11
- package/lib/agents/stage-telemetry.js +10 -1
- package/lib/commands/active.js +1 -1
- package/lib/commands/draft.js +153 -15
- package/lib/commands/integrate.js +9 -4
- package/lib/commands/mission-start.js +15 -4
- package/lib/commands/stats-backfill.js +14 -16
- package/lib/commands/stats.js +198 -52
- package/lib/core/mission-utils.js +19 -9
- package/lib/core/product-config.js +8 -1
- package/lib/review/review-commands.js +3 -1
- package/lib/review/review-loop.js +90 -24
- package/lib/review/review.js +1 -1
- package/lib/tools/backlog.js +86 -23
- package/lib/tools/gatekeeper.js +6 -3
- package/lib/tools/setup-review.js +2 -2
- package/package.json +1 -1
- package/prompts/draft.md +1 -1
|
@@ -33,14 +33,45 @@ const { resolveStageTelemetry } = require('../agents/stage-telemetry');
|
|
|
33
33
|
// guarantees at most one of implementer/reviewer is codex); other families
|
|
34
34
|
// record honest zeros (task-1251).
|
|
35
35
|
//
|
|
36
|
-
// Codex writes a fresh-counter rollout per
|
|
37
|
-
// across every rollout since the
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
// Codex writes a fresh-counter rollout per launch, so we sum total_token_usage
|
|
37
|
+
// across every rollout since the FIRST launch for this stored mission phase and
|
|
38
|
+
// agent family. For non-Codex families we accumulate launcher-attached telemetry
|
|
39
|
+
// one launch at a time, de-duped via review-state metadata, so Claude/Qwen rows
|
|
40
|
+
// are cumulative too. A family switch creates a separate row instead of mixing
|
|
41
|
+
// agents in one record.
|
|
42
|
+
function stageLaunchFingerprint(agentFamily, result) {
|
|
43
|
+
return [
|
|
44
|
+
String(agentFamily || '').trim().toLowerCase(),
|
|
45
|
+
result && result.sessionId ? String(result.sessionId) : '',
|
|
46
|
+
result && result.startedAt ? String(result.startedAt) : '',
|
|
47
|
+
result && result.endedAt ? String(result.endedAt) : '',
|
|
48
|
+
result && result.status !== undefined && result.status !== null ? String(result.status) : '',
|
|
49
|
+
].join('|');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function markStageLaunchRecorded(state, { stage, agentFamily, result, slug, worktree, writeReviewStateFn = writeReviewState } = {}) {
|
|
53
|
+
if (!state || !agentFamily) return true;
|
|
54
|
+
const key = stageWindowKey(stage, agentFamily);
|
|
55
|
+
const fingerprint = stageLaunchFingerprint(agentFamily, result);
|
|
56
|
+
const recordedStageLaunches = state.metadata && typeof state.metadata === 'object'
|
|
57
|
+
? (state.metadata.recordedStageLaunches || {})
|
|
58
|
+
: {};
|
|
59
|
+
const recorded = Array.isArray(recordedStageLaunches[key]) ? recordedStageLaunches[key] : [];
|
|
60
|
+
if (recorded.includes(fingerprint)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (!state.metadata || typeof state.metadata !== 'object') {
|
|
64
|
+
state.metadata = {};
|
|
65
|
+
}
|
|
66
|
+
state.metadata.recordedStageLaunches = {
|
|
67
|
+
...(state.metadata.recordedStageLaunches || {}),
|
|
68
|
+
[key]: [...recorded, fingerprint].slice(-20),
|
|
69
|
+
};
|
|
70
|
+
writeReviewStateFn(slug, state, worktree);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function recordStageStatsSafe(kind, { stage, slug, rootDir, worktree, implementer, reviewer, result, sinceMs, log, error, state, writeReviewStateFn = writeReviewState }) {
|
|
44
75
|
let durationMinutes = 0;
|
|
45
76
|
if (result && result.startedAt && result.endedAt) {
|
|
46
77
|
durationMinutes = (Date.parse(result.endedAt) - Date.parse(result.startedAt)) / 60000;
|
|
@@ -51,8 +82,18 @@ function recordStageStatsSafe(kind, { slug, rootDir, worktree, implementer, revi
|
|
|
51
82
|
// (task-1274), bounded to this stage by sinceMs (task-1285 F6).
|
|
52
83
|
const telemetry = resolveStageTelemetry({ worktree, result, sinceMs });
|
|
53
84
|
try {
|
|
54
|
-
const
|
|
55
|
-
|
|
85
|
+
const actorFamily = kind === 'review' ? reviewer : implementer;
|
|
86
|
+
if (state && !markStageLaunchRecorded(state, {
|
|
87
|
+
stage,
|
|
88
|
+
agentFamily: actorFamily,
|
|
89
|
+
result,
|
|
90
|
+
slug,
|
|
91
|
+
worktree,
|
|
92
|
+
writeReviewStateFn
|
|
93
|
+
})) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
stats.accumulateStageStats({ stage, slug, rootDir, implementer, reviewer, telemetry, durationMinutes });
|
|
56
97
|
} catch (err) {
|
|
57
98
|
// Best-effort: never escalate to the fatal `error` channel.
|
|
58
99
|
log(fmt.status('WARN', `Could not record ${kind} stats for ${slug}: ${err.message}`));
|
|
@@ -135,6 +176,25 @@ function persistNormalizedPhaseRepair(slug, state, worktree, { log = fmt.log.pla
|
|
|
135
176
|
state.phaseOriginal = null;
|
|
136
177
|
}
|
|
137
178
|
|
|
179
|
+
function stageWindowKey(stage, agentFamily) {
|
|
180
|
+
const normalizedStage = String(stage || 'default').trim().toLowerCase() || 'default';
|
|
181
|
+
const normalizedAgent = String(agentFamily || '').trim().toLowerCase();
|
|
182
|
+
return `${normalizedStage}:${normalizedAgent}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Window the Codex telemetry read to the CURRENT launch's start so each round
|
|
186
|
+
// contributes only its own rollouts. Earlier rounds' rollouts have an mtime
|
|
187
|
+
// strictly before this launch's startedAt and are excluded, so the per-round
|
|
188
|
+
// deltas accumulate (via accumulateStageStats) to the family's true total
|
|
189
|
+
// instead of re-summing a cumulative-since-first-launch window every round.
|
|
190
|
+
// Resume-idempotency is provided separately by the launch fingerprint in
|
|
191
|
+
// markStageLaunchRecorded, so the window itself does not need to persist.
|
|
192
|
+
function stageLaunchSinceMs(result) {
|
|
193
|
+
const startedAt = result && result.startedAt ? String(result.startedAt) : '';
|
|
194
|
+
const startedMs = startedAt ? Date.parse(startedAt) : NaN;
|
|
195
|
+
return Number.isFinite(startedMs) ? startedMs : 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
138
198
|
// ============================================================================
|
|
139
199
|
// Main Review Loop
|
|
140
200
|
// ============================================================================
|
|
@@ -200,7 +260,8 @@ async function startReviewLoop(slug, opts = {}) {
|
|
|
200
260
|
gitFn = git,
|
|
201
261
|
isReviewProviderEnabledFn = undefined,
|
|
202
262
|
legacyIsForgejoReviewEnabledFn = null,
|
|
203
|
-
isForgejoReviewEnabledFn = null
|
|
263
|
+
isForgejoReviewEnabledFn = null,
|
|
264
|
+
recordStageStatsSafeFn = () => {}
|
|
204
265
|
} = opts;
|
|
205
266
|
let prNumber = null;
|
|
206
267
|
isContinue = Boolean(isContinue || continueFlag);
|
|
@@ -635,13 +696,14 @@ async function startReviewLoop(slug, opts = {}) {
|
|
|
635
696
|
|
|
636
697
|
// Record review-stage telemetry immediately after the reviewer runs,
|
|
637
698
|
// so the worktree's newest codex rollout is this reviewer's session.
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
699
|
+
// The window is bounded to this round's launch so each round adds
|
|
700
|
+
// only its own usage; accumulateStageStats sums the rounds into one
|
|
701
|
+
// cumulative row per reviewer family.
|
|
702
|
+
const reviewSinceMs = stageLaunchSinceMs(reviewerLaunchResult && reviewerLaunchResult.result);
|
|
703
|
+
recordStageStatsSafeFn('review', {
|
|
704
|
+
stage: 'review', slug, rootDir: worktree, worktree, reviewer, implementer,
|
|
643
705
|
result: reviewerLaunchResult && reviewerLaunchResult.result,
|
|
644
|
-
sinceMs:
|
|
706
|
+
sinceMs: reviewSinceMs, log, error, state, writeReviewStateFn
|
|
645
707
|
});
|
|
646
708
|
}
|
|
647
709
|
|
|
@@ -850,13 +912,15 @@ async function startReviewLoop(slug, opts = {}) {
|
|
|
850
912
|
state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
|
|
851
913
|
});
|
|
852
914
|
|
|
853
|
-
// Record
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
915
|
+
// Record follow-up telemetry immediately after the implementer runs.
|
|
916
|
+
// This is intentionally a separate stored phase from the initial
|
|
917
|
+
// execute/active launch. The window is bounded to this round's launch
|
|
918
|
+
// so each act-on-review round adds only its own usage.
|
|
919
|
+
const followUpSinceMs = stageLaunchSinceMs(implementerLaunchResult && implementerLaunchResult.result);
|
|
920
|
+
recordStageStatsSafeFn('active', {
|
|
921
|
+
stage: 'follow-up', slug, rootDir: worktree, worktree, implementer, reviewer,
|
|
858
922
|
result: implementerLaunchResult && implementerLaunchResult.result,
|
|
859
|
-
sinceMs:
|
|
923
|
+
sinceMs: followUpSinceMs, log, error, state, writeReviewStateFn
|
|
860
924
|
});
|
|
861
925
|
}
|
|
862
926
|
|
|
@@ -1000,5 +1064,7 @@ module.exports = {
|
|
|
1000
1064
|
rebaseBeforeReviewRound,
|
|
1001
1065
|
applyAgentFallback,
|
|
1002
1066
|
persistNormalizedPhaseRepair,
|
|
1003
|
-
|
|
1067
|
+
stageLaunchSinceMs,
|
|
1068
|
+
startReviewLoop,
|
|
1069
|
+
recordStageStatsSafe
|
|
1004
1070
|
};
|
package/lib/review/review.js
CHANGED
|
@@ -16,7 +16,7 @@ const { POLL_TIMEOUT, delay, resolvePollIntervalMs, resolvePollTimeoutMs, format
|
|
|
16
16
|
const { buildMetadataFooter, reviewArtifactPath, readArtifactFile, deleteArtifactFile, normalizeReviewVerdict, normalizeDisposition, postWorkflowComment, postWorkflowReview, consumeReviewerArtifacts, consumeImplementerArtifacts } = require('./review-artifacts');
|
|
17
17
|
|
|
18
18
|
// Re-export from review-loop module
|
|
19
|
-
const { maybeUpdateGraphifyBeforeReview, commitSafeMissionArtifacts, rebaseBeforeReviewRound, applyAgentFallback, persistNormalizedPhaseRepair, startReviewLoop } = require('./review-loop');
|
|
19
|
+
const { maybeUpdateGraphifyBeforeReview, commitSafeMissionArtifacts, rebaseBeforeReviewRound, applyAgentFallback, persistNormalizedPhaseRepair, startReviewLoop, recordStageStatsSafe } = require('./review-loop');
|
|
20
20
|
|
|
21
21
|
// Re-export from review-commands module
|
|
22
22
|
const {
|
package/lib/tools/backlog.js
CHANGED
|
@@ -3,15 +3,18 @@ const path = require('path');
|
|
|
3
3
|
const { git } = require('../core/git');
|
|
4
4
|
const { WORKFLOW_AGENT_NAMES } = require('../agents/agents');
|
|
5
5
|
const fmt = require('../core/fmt');
|
|
6
|
+
const { resolveTaskStorage } = require('../core/product-config');
|
|
6
7
|
|
|
7
8
|
function getSupportedAgents() {
|
|
8
9
|
return WORKFLOW_AGENT_NAMES;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function getTaskStorage(rootDir = process.cwd()) {
|
|
13
|
+
return resolveTaskStorage(rootDir);
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
function findTaskFiles(slug, rootDir = process.cwd()) {
|
|
12
|
-
const tasksDir =
|
|
13
|
-
const completedDir = path.join(rootDir, 'backlog', 'completed');
|
|
14
|
-
const archivedDir = path.join(rootDir, 'backlog', 'archive', 'tasks');
|
|
17
|
+
const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
|
|
15
18
|
const sortByBacklogState = (filePath) => {
|
|
16
19
|
if (filePath.startsWith(tasksDir + path.sep)) return 0;
|
|
17
20
|
if (filePath.startsWith(completedDir + path.sep)) return 1;
|
|
@@ -35,7 +38,7 @@ function findTaskFiles(slug, rootDir = process.cwd()) {
|
|
|
35
38
|
function resolveTaskFile(slug, rootDir = process.cwd()) {
|
|
36
39
|
let matches = findTaskFiles(slug, rootDir);
|
|
37
40
|
const normalizedId = slug.toUpperCase();
|
|
38
|
-
const archivedDir =
|
|
41
|
+
const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
|
|
39
42
|
const preferSameTaskInHigherPriorityDir = (candidateMatches) => {
|
|
40
43
|
if (candidateMatches.length < 2) return null;
|
|
41
44
|
const [preferred, ...rest] = candidateMatches;
|
|
@@ -56,8 +59,6 @@ function resolveTaskFile(slug, rootDir = process.cwd()) {
|
|
|
56
59
|
|
|
57
60
|
if (matches.length === 0) {
|
|
58
61
|
// Hardening: If no prefix match, search ALL task files for an exact ID match
|
|
59
|
-
const tasksDir = path.join(rootDir, 'backlog', 'tasks');
|
|
60
|
-
const completedDir = path.join(rootDir, 'backlog', 'completed');
|
|
61
62
|
const allFiles = [
|
|
62
63
|
...(fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir).map(f => path.join(tasksDir, f)) : []),
|
|
63
64
|
...(fs.existsSync(completedDir) ? fs.readdirSync(completedDir).map(f => path.join(completedDir, f)) : []),
|
|
@@ -121,6 +122,9 @@ function findTaskFile(slug, rootDir = process.cwd()) {
|
|
|
121
122
|
|
|
122
123
|
function reportTaskResolution(result, slug, log = fmt.log.plain) {
|
|
123
124
|
if (result.ok) return;
|
|
125
|
+
const { tasksDir, completedDir } = getTaskStorage(process.cwd());
|
|
126
|
+
const taskHint = path.relative(process.cwd(), tasksDir).split(path.sep).join('/');
|
|
127
|
+
const completedHint = path.relative(process.cwd(), completedDir).split(path.sep).join('/');
|
|
124
128
|
|
|
125
129
|
if (result.reason === 'ambiguous') {
|
|
126
130
|
log(fmt.status('FAIL', `Backlog task resolution is ambiguous for slug: ${fmt.slug(slug)}`));
|
|
@@ -128,21 +132,32 @@ function reportTaskResolution(result, slug, log = fmt.log.plain) {
|
|
|
128
132
|
result.matches.forEach(m => log(` - ${m}`));
|
|
129
133
|
log(fmt.status('INFO', 'Repair: Ensure only one task has the filename prefix or matching "id:" frontmatter.'));
|
|
130
134
|
} else {
|
|
131
|
-
log(fmt.status('FAIL', `Backlog task for ${fmt.slug(slug)} not found in
|
|
135
|
+
log(fmt.status('FAIL', `Backlog task for ${fmt.slug(slug)} not found in ${fmt.path(taskHint)}/ or ${fmt.path(completedHint)}/.`));
|
|
132
136
|
log(fmt.status('INFO', 'Create the task file first.'));
|
|
133
|
-
log(fmt.status('INFO', 'Repair:
|
|
134
|
-
log(fmt.status('INFO', `
|
|
135
|
-
log(fmt.status('INFO', `Without Backlog.md, create ${fmt.path(`backlog/tasks/${slug} - <title>.md`)} with frontmatter id ${fmt.bold(slug.toUpperCase())}.`));
|
|
137
|
+
log(fmt.status('INFO', 'Repair: create the task with your task adapter, or add a markdown task file manually.'));
|
|
138
|
+
log(fmt.status('INFO', `Manual fallback: create ${fmt.path(`${taskHint}/${slug} - <title>.md`)} with frontmatter id ${fmt.bold(slug.toUpperCase())}.`));
|
|
136
139
|
}
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
function taskIdFromFilename(file) {
|
|
143
|
+
// Extract ID from filename prefix (e.g., task-093 or task-093.01)
|
|
144
|
+
const filenameMatch = file.match(/^(task-\d+(?:\.\d+)?)/i);
|
|
145
|
+
return filenameMatch ? filenameMatch[1].toUpperCase() : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
139
148
|
function checkBacklogIntegrity(rootDir = process.cwd(), slug = null) {
|
|
140
|
-
const tasksDir =
|
|
141
|
-
const completedDir = path.join(rootDir, 'backlog', 'completed');
|
|
149
|
+
const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
|
|
142
150
|
const issues = [];
|
|
143
151
|
const normalizedSlug = slug ? slug.toLowerCase() : null;
|
|
144
152
|
|
|
145
|
-
|
|
153
|
+
// Track where each task id lives so we can detect the same id appearing in
|
|
154
|
+
// both backlog/tasks/ and backlog/completed/ (or backlog/archive/) — the
|
|
155
|
+
// reorder-recreates-completed-task defect (TASK-1343). The completed/archive
|
|
156
|
+
// copy is canonical; a backlog/tasks/ copy alongside it is stale.
|
|
157
|
+
const tasksLocations = new Map(); // id -> rel path in tasks/
|
|
158
|
+
const canonicalLocations = new Map(); // id -> rel path in completed|archive
|
|
159
|
+
|
|
160
|
+
const scan = (dir, { canonical = false } = {}) => {
|
|
146
161
|
if (!fs.existsSync(dir)) return;
|
|
147
162
|
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
148
163
|
for (const file of files) {
|
|
@@ -150,22 +165,26 @@ function checkBacklogIntegrity(rootDir = process.cwd(), slug = null) {
|
|
|
150
165
|
continue;
|
|
151
166
|
}
|
|
152
167
|
const filePath = path.join(dir, file);
|
|
168
|
+
const filenameId = taskIdFromFilename(file);
|
|
169
|
+
if (!filenameId) continue;
|
|
170
|
+
const relPath = path.relative(rootDir, filePath);
|
|
171
|
+
|
|
172
|
+
if (canonical) {
|
|
173
|
+
if (!canonicalLocations.has(filenameId)) canonicalLocations.set(filenameId, relPath);
|
|
174
|
+
} else if (!tasksLocations.has(filenameId)) {
|
|
175
|
+
tasksLocations.set(filenameId, relPath);
|
|
176
|
+
}
|
|
177
|
+
|
|
153
178
|
try {
|
|
154
179
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
155
180
|
|
|
156
|
-
// Extract ID from filename prefix (e.g., task-093 or task-093.01)
|
|
157
|
-
const filenameMatch = file.match(/^(task-\d+(?:\.\d+)?)/i);
|
|
158
|
-
if (!filenameMatch) continue;
|
|
159
|
-
|
|
160
|
-
const filenameId = filenameMatch[1].toUpperCase();
|
|
161
|
-
|
|
162
181
|
// Extract ID from frontmatter
|
|
163
182
|
const idMatch = content.match(/^id:\s*([^\r\n]+)/m);
|
|
164
183
|
if (idMatch) {
|
|
165
184
|
const frontmatterId = idMatch[1].trim().toUpperCase();
|
|
166
185
|
if (frontmatterId !== filenameId) {
|
|
167
186
|
issues.push({
|
|
168
|
-
file:
|
|
187
|
+
file: relPath,
|
|
169
188
|
type: 'id-mismatch',
|
|
170
189
|
filenameId,
|
|
171
190
|
frontmatterId
|
|
@@ -179,10 +198,51 @@ function checkBacklogIntegrity(rootDir = process.cwd(), slug = null) {
|
|
|
179
198
|
};
|
|
180
199
|
|
|
181
200
|
scan(tasksDir);
|
|
182
|
-
scan(completedDir);
|
|
201
|
+
scan(completedDir, { canonical: true });
|
|
202
|
+
scan(archivedDir, { canonical: true });
|
|
203
|
+
|
|
204
|
+
for (const [id, taskPath] of tasksLocations) {
|
|
205
|
+
const canonicalPath = canonicalLocations.get(id);
|
|
206
|
+
if (canonicalPath) {
|
|
207
|
+
issues.push({
|
|
208
|
+
file: taskPath,
|
|
209
|
+
type: 'duplicate-completed',
|
|
210
|
+
taskId: id,
|
|
211
|
+
canonicalFile: canonicalPath
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
183
216
|
return issues;
|
|
184
217
|
}
|
|
185
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Drop backlog/tasks/ copies of task ids whose canonical record already lives
|
|
221
|
+
* in backlog/completed/ or backlog/archive/. Used to make any board mutation
|
|
222
|
+
* (reorder / ordinal write) completed- and archive-aware: the completed copy is
|
|
223
|
+
* treated as canonical and the stale backlog/tasks/ copy is removed. Returns
|
|
224
|
+
* the list of removed { taskId, file, canonicalFile } records. See TASK-1343.
|
|
225
|
+
*/
|
|
226
|
+
function pruneStaleBacklogDuplicates(rootDir = process.cwd()) {
|
|
227
|
+
const removed = [];
|
|
228
|
+
const duplicates = checkBacklogIntegrity(rootDir)
|
|
229
|
+
.filter(issue => issue.type === 'duplicate-completed');
|
|
230
|
+
|
|
231
|
+
for (const dup of duplicates) {
|
|
232
|
+
const absPath = path.join(rootDir, dup.file);
|
|
233
|
+
try {
|
|
234
|
+
if (fs.existsSync(absPath)) {
|
|
235
|
+
fs.rmSync(absPath);
|
|
236
|
+
removed.push({ taskId: dup.taskId, file: dup.file, canonicalFile: dup.canonicalFile });
|
|
237
|
+
}
|
|
238
|
+
} catch (_) {
|
|
239
|
+
// ignore removal errors; the integrity gate will still surface the duplicate
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return removed;
|
|
244
|
+
}
|
|
245
|
+
|
|
186
246
|
function getTaskStatus(taskFilePath) {
|
|
187
247
|
if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
|
|
188
248
|
const content = fs.readFileSync(taskFilePath, 'utf8');
|
|
@@ -233,8 +293,7 @@ function completeTask(slug, rootDir = process.cwd()) {
|
|
|
233
293
|
|
|
234
294
|
const taskFilePath = resolution.taskFile;
|
|
235
295
|
const fileName = path.basename(taskFilePath);
|
|
236
|
-
const tasksDir =
|
|
237
|
-
const completedDir = path.join(rootDir, 'backlog', 'completed');
|
|
296
|
+
const { tasksDir, completedDir } = getTaskStorage(rootDir);
|
|
238
297
|
|
|
239
298
|
if (!taskFilePath.includes(tasksDir)) {
|
|
240
299
|
// Already in completed or somewhere else
|
|
@@ -501,6 +560,7 @@ function getTaskClassification(taskFilePath) {
|
|
|
501
560
|
.map(s => s.toLowerCase());
|
|
502
561
|
if (labels.includes('ai_sdlc')) matches.add('ai_sdlc');
|
|
503
562
|
if (labels.includes('user_value')) matches.add('user_value');
|
|
563
|
+
if (labels.includes('unknown')) matches.add('unknown');
|
|
504
564
|
}
|
|
505
565
|
|
|
506
566
|
const inlineMatch = content.match(/^labels:[ \t]*\[(.*?)\]/m);
|
|
@@ -510,6 +570,7 @@ function getTaskClassification(taskFilePath) {
|
|
|
510
570
|
.map(s => s.toLowerCase());
|
|
511
571
|
if (labels.includes('ai_sdlc')) matches.add('ai_sdlc');
|
|
512
572
|
if (labels.includes('user_value')) matches.add('user_value');
|
|
573
|
+
if (labels.includes('unknown')) matches.add('unknown');
|
|
513
574
|
}
|
|
514
575
|
|
|
515
576
|
return matches.size === 1 ? [...matches][0] : null;
|
|
@@ -662,6 +723,8 @@ module.exports = {
|
|
|
662
723
|
resolveTaskFile,
|
|
663
724
|
reportTaskResolution,
|
|
664
725
|
checkBacklogIntegrity,
|
|
726
|
+
pruneStaleBacklogDuplicates,
|
|
727
|
+
getTaskStorage,
|
|
665
728
|
getTaskStatus,
|
|
666
729
|
setTaskStatus,
|
|
667
730
|
transitionTask,
|
package/lib/tools/gatekeeper.js
CHANGED
|
@@ -3,7 +3,7 @@ const path = require('path');
|
|
|
3
3
|
const fmt = require('../core/fmt');
|
|
4
4
|
|
|
5
5
|
const { findMissionDir, findCheckpoints, missionDirForSlug, missionBranchName } = require('../core/mission-utils');
|
|
6
|
-
const { resolveTaskFile } = require('./backlog');
|
|
6
|
+
const { resolveTaskFile, getTaskStorage } = require('./backlog');
|
|
7
7
|
const { readToken, postReview } = require('./forgejo');
|
|
8
8
|
|
|
9
9
|
const DEFAULT_GATEKEEPER_USER = 'forgejo-gatekeeper';
|
|
@@ -42,8 +42,11 @@ function checkMandatoryFiles(slug, options = {}) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const taskResolution = resolveTaskFileFn(slug, rootDir);
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const missionArtifactsPresent = Boolean(missionPath && fs.existsSync(missionPath) && missionDir && findCheckpointsFn(missionDir).length > 0);
|
|
46
|
+
if ((!taskResolution || !taskResolution.ok) && !missionArtifactsPresent) {
|
|
47
|
+
const { tasksDir } = getTaskStorage(rootDir);
|
|
48
|
+
const taskDirRel = path.relative(rootDir, tasksDir).split(path.sep).join('/');
|
|
49
|
+
missing.push(`${taskDirRel}/${slug} - *.md`);
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
return { ok: missing.length === 0, missing };
|
|
@@ -90,7 +90,7 @@ function detectPrimaryBranchDefault(rootDir = process.cwd(), options = {}) {
|
|
|
90
90
|
|
|
91
91
|
function standardLayoutDescription() {
|
|
92
92
|
return [
|
|
93
|
-
'standard =
|
|
93
|
+
'standard = markdown task storage in `backlog/`',
|
|
94
94
|
'missions in `docs/missions/`',
|
|
95
95
|
'mission/* branches with auto-detected `main`/`master` primary',
|
|
96
96
|
'worktrees in `../<repo>-<slug>`',
|
|
@@ -613,7 +613,7 @@ async function collectWizardAnswers(rootDir = process.cwd(), options = {}) {
|
|
|
613
613
|
|| defaultProductName(rootDir);
|
|
614
614
|
log(fmt.status('INFO', `Standard workflow layout: ${standardLayoutDescription()}`));
|
|
615
615
|
const useDefaults = parseYesNo(
|
|
616
|
-
await promptFn('Use the standard
|
|
616
|
+
await promptFn('Use the standard markdown task workflow layout and defaults? [Y/n]: '),
|
|
617
617
|
true
|
|
618
618
|
);
|
|
619
619
|
|
package/package.json
CHANGED
package/prompts/draft.md
CHANGED
|
@@ -9,7 +9,7 @@ Drafting requirements:
|
|
|
9
9
|
- fill every scaffolded section in `{{missionPath}}` with concrete, non-generic content (no placeholders, no "TBD")
|
|
10
10
|
- include a Goal, Why now, Scope, Out of scope, Success criteria, Risks/assumptions, Checkpoints, Gates, Restricted areas, and Stop rules
|
|
11
11
|
- success criteria must be specific enough to derive a goal-check table during execution
|
|
12
|
-
-
|
|
12
|
+
- {{classificationInstructions}}
|
|
13
13
|
- preserve `{{taskPath}}`: update content as needed but do not delete, rename, or move the file
|
|
14
14
|
- do not edit the backlog `assignee` field; the workflow records ownership itself
|
|
15
15
|
|