@papi-ai/server 0.7.34 → 0.7.35
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/dist/backfill-cycle-metrics.js +4329 -0
- package/dist/index.js +1694 -489
- package/dist/prompts.js +58 -8
- package/package.json +2 -1
- package/skills/papi-cycle/papi-plan/SKILL.md +14 -0
- package/skills/papi-cycle/papi-strategy/SKILL.md +1 -1
package/dist/index.js
CHANGED
|
@@ -51,6 +51,7 @@ __export(git_exports, {
|
|
|
51
51
|
hasRemote: () => hasRemote,
|
|
52
52
|
hasUncommittedChanges: () => hasUncommittedChanges,
|
|
53
53
|
hasUnpushedCommits: () => hasUnpushedCommits,
|
|
54
|
+
isBranchMergedInto: () => isBranchMergedInto,
|
|
54
55
|
isGhAvailable: () => isGhAvailable,
|
|
55
56
|
isGitAvailable: () => isGitAvailable,
|
|
56
57
|
isGitRepo: () => isGitRepo,
|
|
@@ -798,6 +799,15 @@ function listGroupedCycleBranches(cwd, cycleNum, baseBranch) {
|
|
|
798
799
|
}
|
|
799
800
|
return [...seen];
|
|
800
801
|
}
|
|
802
|
+
function isBranchMergedInto(cwd, branch, baseBranch) {
|
|
803
|
+
try {
|
|
804
|
+
const tip = execFileSync("git", ["rev-parse", branch], { cwd, encoding: "utf-8" }).trim();
|
|
805
|
+
execFileSync("git", ["merge-base", "--is-ancestor", tip, baseBranch], { cwd, stdio: "ignore" });
|
|
806
|
+
return true;
|
|
807
|
+
} catch {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
801
811
|
function pickModuleCycleBranch(candidates, cycleNumber, module) {
|
|
802
812
|
if (candidates.length === 0) return void 0;
|
|
803
813
|
const expected = cycleBranchName(cycleNumber, module);
|
|
@@ -1199,6 +1209,17 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
|
|
|
1199
1209
|
updateTaskStatus(id, status) {
|
|
1200
1210
|
return this.invoke("updateTaskStatus", [id, status]);
|
|
1201
1211
|
}
|
|
1212
|
+
// task-2155 (MU-3 task B): hosted/proxy claim write-path. The data-proxy gates
|
|
1213
|
+
// these via WRITE_METHODS (active editor may write, viewer cannot) and binds the
|
|
1214
|
+
// assignee to the bearer-derived caller server-side — the assigneeId passed here
|
|
1215
|
+
// is ignored by the edge function, so it cannot be spoofed to claim on another's
|
|
1216
|
+
// behalf. Mirrors pg-papi-adapter.claimTask/unclaimTask (proxy-parity enforced).
|
|
1217
|
+
claimTask(taskId, assigneeId) {
|
|
1218
|
+
return this.invoke("claimTask", [taskId, assigneeId]);
|
|
1219
|
+
}
|
|
1220
|
+
unclaimTask(taskId, assigneeId) {
|
|
1221
|
+
return this.invoke("unclaimTask", [taskId, assigneeId]);
|
|
1222
|
+
}
|
|
1202
1223
|
recordTransition(taskId, fromStatus, toStatus, changedBy) {
|
|
1203
1224
|
return this.invoke("recordTransition", [taskId, fromStatus, toStatus, changedBy]);
|
|
1204
1225
|
}
|
|
@@ -3075,7 +3096,7 @@ var init_connection = __esm({
|
|
|
3075
3096
|
|
|
3076
3097
|
// ../../node_modules/postgres/src/subscribe.js
|
|
3077
3098
|
function Subscribe(postgres2, options) {
|
|
3078
|
-
const subscribers = /* @__PURE__ */ new Map(), slot = "postgresjs_" + Math.random().toString(36).slice(2),
|
|
3099
|
+
const subscribers = /* @__PURE__ */ new Map(), slot = "postgresjs_" + Math.random().toString(36).slice(2), state = {};
|
|
3079
3100
|
let connection2, stream, ended = false;
|
|
3080
3101
|
const sql = subscribe.sql = postgres2({
|
|
3081
3102
|
...options,
|
|
@@ -3092,7 +3113,7 @@ function Subscribe(postgres2, options) {
|
|
|
3092
3113
|
if (ended)
|
|
3093
3114
|
return;
|
|
3094
3115
|
stream = null;
|
|
3095
|
-
|
|
3116
|
+
state.pid = state.secret = void 0;
|
|
3096
3117
|
connected(await init(sql, slot, options.publications));
|
|
3097
3118
|
subscribers.forEach((event) => event.forEach(({ onsubscribe }) => onsubscribe()));
|
|
3098
3119
|
},
|
|
@@ -3123,13 +3144,13 @@ function Subscribe(postgres2, options) {
|
|
|
3123
3144
|
connected(x);
|
|
3124
3145
|
onsubscribe();
|
|
3125
3146
|
stream && stream.on("error", onerror);
|
|
3126
|
-
return { unsubscribe, state
|
|
3147
|
+
return { unsubscribe, state, sql };
|
|
3127
3148
|
});
|
|
3128
3149
|
}
|
|
3129
3150
|
function connected(x) {
|
|
3130
3151
|
stream = x.stream;
|
|
3131
|
-
|
|
3132
|
-
|
|
3152
|
+
state.pid = x.state.pid;
|
|
3153
|
+
state.secret = x.state.secret;
|
|
3133
3154
|
}
|
|
3134
3155
|
async function init(sql2, slot2, publications) {
|
|
3135
3156
|
if (!publications)
|
|
@@ -3141,7 +3162,7 @@ function Subscribe(postgres2, options) {
|
|
|
3141
3162
|
const stream2 = await sql2.unsafe(
|
|
3142
3163
|
`START_REPLICATION SLOT ${slot2} LOGICAL ${x.consistent_point} (proto_version '1', publication_names '${publications}')`
|
|
3143
3164
|
).writable();
|
|
3144
|
-
const
|
|
3165
|
+
const state2 = {
|
|
3145
3166
|
lsn: Buffer.concat(x.consistent_point.split("/").map((x2) => Buffer.from(("00000000" + x2).slice(-8), "hex")))
|
|
3146
3167
|
};
|
|
3147
3168
|
stream2.on("data", data);
|
|
@@ -3153,9 +3174,9 @@ function Subscribe(postgres2, options) {
|
|
|
3153
3174
|
}
|
|
3154
3175
|
function data(x2) {
|
|
3155
3176
|
if (x2[0] === 119) {
|
|
3156
|
-
parse(x2.subarray(25),
|
|
3177
|
+
parse(x2.subarray(25), state2, sql2.options.parsers, handle, options.transform);
|
|
3157
3178
|
} else if (x2[0] === 107 && x2[17]) {
|
|
3158
|
-
|
|
3179
|
+
state2.lsn = x2.subarray(1, 9);
|
|
3159
3180
|
pong();
|
|
3160
3181
|
}
|
|
3161
3182
|
}
|
|
@@ -3171,7 +3192,7 @@ function Subscribe(postgres2, options) {
|
|
|
3171
3192
|
function pong() {
|
|
3172
3193
|
const x2 = Buffer.alloc(34);
|
|
3173
3194
|
x2[0] = "r".charCodeAt(0);
|
|
3174
|
-
x2.fill(
|
|
3195
|
+
x2.fill(state2.lsn, 1);
|
|
3175
3196
|
x2.writeBigInt64BE(BigInt(Date.now() - Date.UTC(2e3, 0, 1)) * BigInt(1e3), 25);
|
|
3176
3197
|
stream2.write(x2);
|
|
3177
3198
|
}
|
|
@@ -3183,12 +3204,12 @@ function Subscribe(postgres2, options) {
|
|
|
3183
3204
|
function Time(x) {
|
|
3184
3205
|
return new Date(Date.UTC(2e3, 0, 1) + Number(x / BigInt(1e3)));
|
|
3185
3206
|
}
|
|
3186
|
-
function parse(x,
|
|
3207
|
+
function parse(x, state, parsers2, handle, transform) {
|
|
3187
3208
|
const char = (acc, [k, v]) => (acc[k.charCodeAt(0)] = v, acc);
|
|
3188
3209
|
Object.entries({
|
|
3189
3210
|
R: (x2) => {
|
|
3190
3211
|
let i = 1;
|
|
3191
|
-
const r =
|
|
3212
|
+
const r = state[x2.readUInt32BE(i)] = {
|
|
3192
3213
|
schema: x2.toString("utf8", i += 4, i = x2.indexOf(0, i)) || "pg_catalog",
|
|
3193
3214
|
table: x2.toString("utf8", i + 1, i = x2.indexOf(0, i + 1)),
|
|
3194
3215
|
columns: Array(x2.readUInt16BE(i += 2)),
|
|
@@ -3215,12 +3236,12 @@ function parse(x, state2, parsers2, handle, transform) {
|
|
|
3215
3236
|
},
|
|
3216
3237
|
// Origin
|
|
3217
3238
|
B: (x2) => {
|
|
3218
|
-
|
|
3219
|
-
|
|
3239
|
+
state.date = Time(x2.readBigInt64BE(9));
|
|
3240
|
+
state.lsn = x2.subarray(1, 9);
|
|
3220
3241
|
},
|
|
3221
3242
|
I: (x2) => {
|
|
3222
3243
|
let i = 1;
|
|
3223
|
-
const relation =
|
|
3244
|
+
const relation = state[x2.readUInt32BE(i)];
|
|
3224
3245
|
const { row } = tuples(x2, relation.columns, i += 7, transform);
|
|
3225
3246
|
handle(row, {
|
|
3226
3247
|
command: "insert",
|
|
@@ -3229,7 +3250,7 @@ function parse(x, state2, parsers2, handle, transform) {
|
|
|
3229
3250
|
},
|
|
3230
3251
|
D: (x2) => {
|
|
3231
3252
|
let i = 1;
|
|
3232
|
-
const relation =
|
|
3253
|
+
const relation = state[x2.readUInt32BE(i)];
|
|
3233
3254
|
i += 4;
|
|
3234
3255
|
const key = x2[i] === 75;
|
|
3235
3256
|
handle(
|
|
@@ -3243,7 +3264,7 @@ function parse(x, state2, parsers2, handle, transform) {
|
|
|
3243
3264
|
},
|
|
3244
3265
|
U: (x2) => {
|
|
3245
3266
|
let i = 1;
|
|
3246
|
-
const relation =
|
|
3267
|
+
const relation = state[x2.readUInt32BE(i)];
|
|
3247
3268
|
i += 4;
|
|
3248
3269
|
const key = x2[i] === 75;
|
|
3249
3270
|
const xs = key || x2[i] === 79 ? tuples(x2, relation.columns, i += 3, transform) : null;
|
|
@@ -3954,12 +3975,12 @@ function resolveDatabaseUrl() {
|
|
|
3954
3975
|
function classifyWedged(rows) {
|
|
3955
3976
|
const wedged = [];
|
|
3956
3977
|
for (const r of rows) {
|
|
3957
|
-
const
|
|
3978
|
+
const state = (r.state ?? "").toLowerCase();
|
|
3958
3979
|
const stateAge = Number(r.state_age ?? 0);
|
|
3959
3980
|
const queryAge = Number(r.query_age ?? 0);
|
|
3960
|
-
if (
|
|
3981
|
+
if (state.startsWith("idle in transaction") && stateAge > WEDGED_IDLE_TX_SECONDS) {
|
|
3961
3982
|
wedged.push({ pid: r.pid, state: r.state ?? "unknown", ageSeconds: Math.round(stateAge), reason: "idle-in-transaction" });
|
|
3962
|
-
} else if (
|
|
3983
|
+
} else if (state === "active" && queryAge > WEDGED_ACTIVE_SECONDS) {
|
|
3963
3984
|
wedged.push({ pid: r.pid, state: r.state ?? "unknown", ageSeconds: Math.round(queryAge), reason: "long-active" });
|
|
3964
3985
|
}
|
|
3965
3986
|
}
|
|
@@ -5163,7 +5184,10 @@ function toCycleTask(raw) {
|
|
|
5163
5184
|
closureReason: raw.closure_reason || void 0,
|
|
5164
5185
|
buildHandoff: raw.build_handoff ? parseBuildHandoff(raw.build_handoff) ?? void 0 : void 0,
|
|
5165
5186
|
buildReport: raw.build_report || void 0,
|
|
5166
|
-
scopeClass: raw.scope_class === "brief" ? "brief" : "task"
|
|
5187
|
+
scopeClass: raw.scope_class === "brief" ? "brief" : "task",
|
|
5188
|
+
assigneeId: raw.assignee_id || void 0,
|
|
5189
|
+
claimSource: raw.claim_source === "pool" || raw.claim_source === "self_generated" ? raw.claim_source : void 0,
|
|
5190
|
+
reviewerId: raw.reviewer_id || void 0
|
|
5167
5191
|
};
|
|
5168
5192
|
}
|
|
5169
5193
|
function sanitizeDelimiters(value) {
|
|
@@ -5196,6 +5220,9 @@ function fromCycleTask(task) {
|
|
|
5196
5220
|
if (task.buildHandoff) raw.build_handoff = sanitizeDelimiters(serializeBuildHandoff(task.buildHandoff));
|
|
5197
5221
|
if (task.buildReport) raw.build_report = sanitizeDelimiters(task.buildReport);
|
|
5198
5222
|
if (task.scopeClass && task.scopeClass !== "task") raw.scope_class = task.scopeClass;
|
|
5223
|
+
if (task.assigneeId) raw.assignee_id = task.assigneeId;
|
|
5224
|
+
if (task.claimSource) raw.claim_source = task.claimSource;
|
|
5225
|
+
if (task.reviewerId) raw.reviewer_id = task.reviewerId;
|
|
5199
5226
|
return raw;
|
|
5200
5227
|
}
|
|
5201
5228
|
function mergeConflictHint(content) {
|
|
@@ -5262,6 +5289,7 @@ function filterTasks(tasks, options) {
|
|
|
5262
5289
|
if (options.reviewed !== void 0 && task.reviewed !== options.reviewed) return false;
|
|
5263
5290
|
if (options.module && task.module !== options.module) return false;
|
|
5264
5291
|
if (options.epic && task.epic !== options.epic) return false;
|
|
5292
|
+
if (options.assigneeId && task.assigneeId !== options.assigneeId) return false;
|
|
5265
5293
|
return true;
|
|
5266
5294
|
});
|
|
5267
5295
|
}
|
|
@@ -6043,6 +6071,7 @@ function toCycle(raw) {
|
|
|
6043
6071
|
taskIds: raw.task_ids ?? []
|
|
6044
6072
|
};
|
|
6045
6073
|
if (raw.end_date) cycle.endDate = raw.end_date;
|
|
6074
|
+
if (raw.user_id) cycle.userId = raw.user_id;
|
|
6046
6075
|
return cycle;
|
|
6047
6076
|
}
|
|
6048
6077
|
function fromCycle(cycle) {
|
|
@@ -6056,6 +6085,7 @@ function fromCycle(cycle) {
|
|
|
6056
6085
|
task_ids: cycle.taskIds
|
|
6057
6086
|
};
|
|
6058
6087
|
if (cycle.endDate) raw.end_date = cycle.endDate;
|
|
6088
|
+
if (cycle.userId) raw.user_id = cycle.userId;
|
|
6059
6089
|
return raw;
|
|
6060
6090
|
}
|
|
6061
6091
|
function extractYamlBlock2(content) {
|
|
@@ -6378,6 +6408,66 @@ var MdFileAdapter = class {
|
|
|
6378
6408
|
async updateTaskStatus(id, status) {
|
|
6379
6409
|
return this.updateTask(id, { status });
|
|
6380
6410
|
}
|
|
6411
|
+
/**
|
|
6412
|
+
* task-1763 (C293): atomic compare-and-swap task claim. First-claim-wins —
|
|
6413
|
+
* sets assigneeId only if the task is currently unclaimed. The markdown adapter
|
|
6414
|
+
* is single-process, so the read-check-write is trivially atomic here; the real
|
|
6415
|
+
* concurrency guarantee lives in the pg adapter's RETURNING CAS. Returns the
|
|
6416
|
+
* claimed task, or null if it was already claimed or does not exist.
|
|
6417
|
+
*
|
|
6418
|
+
* task-2071 (MU-3): the pooled-task invariant is `assigneeId == null && cycle
|
|
6419
|
+
* == null` — a task already pulled into someone's cycle is NOT in the pool and
|
|
6420
|
+
* cannot be claimed. Sets claimSource='pool' on success.
|
|
6421
|
+
*/
|
|
6422
|
+
async claimTask(taskId, assigneeId) {
|
|
6423
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
6424
|
+
const tasks = parseBoard(content);
|
|
6425
|
+
const idx = tasks.findIndex((t) => t.id === taskId);
|
|
6426
|
+
if (idx === -1) return null;
|
|
6427
|
+
if (tasks[idx].assigneeId || tasks[idx].cycle != null) return null;
|
|
6428
|
+
tasks[idx] = { ...tasks[idx], assigneeId, claimSource: "pool" };
|
|
6429
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
6430
|
+
return tasks[idx];
|
|
6431
|
+
}
|
|
6432
|
+
/**
|
|
6433
|
+
* task-2071 (C293, MU-3): claimer-only release. Clears assigneeId + claimSource
|
|
6434
|
+
* only if the task is currently assigned to `assigneeId` and has not entered
|
|
6435
|
+
* review. Returns the unclaimed task, or null if the caller is not the claimer,
|
|
6436
|
+
* the task has progressed, or it does not exist.
|
|
6437
|
+
*/
|
|
6438
|
+
async unclaimTask(taskId, assigneeId) {
|
|
6439
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
6440
|
+
const tasks = parseBoard(content);
|
|
6441
|
+
const idx = tasks.findIndex((t2) => t2.id === taskId);
|
|
6442
|
+
if (idx === -1) return null;
|
|
6443
|
+
const t = tasks[idx];
|
|
6444
|
+
if (t.assigneeId !== assigneeId) return null;
|
|
6445
|
+
if (t.status === "In Review" || t.status === "Done") return null;
|
|
6446
|
+
const next = { ...t };
|
|
6447
|
+
delete next.assigneeId;
|
|
6448
|
+
delete next.claimSource;
|
|
6449
|
+
tasks[idx] = next;
|
|
6450
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
6451
|
+
return tasks[idx];
|
|
6452
|
+
}
|
|
6453
|
+
/**
|
|
6454
|
+
* task-2072 (C293, MU-4): atomic review claim. Sets reviewerId only if the task
|
|
6455
|
+
* is In Review and not yet claimed for review (reviewerId == null). First-claim-
|
|
6456
|
+
* wins. Returns the claimed task, or null if already review-claimed / not In
|
|
6457
|
+
* Review / missing.
|
|
6458
|
+
*/
|
|
6459
|
+
async claimReview(taskId, reviewerId) {
|
|
6460
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
6461
|
+
const tasks = parseBoard(content);
|
|
6462
|
+
const idx = tasks.findIndex((t2) => t2.id === taskId);
|
|
6463
|
+
if (idx === -1) return null;
|
|
6464
|
+
const t = tasks[idx];
|
|
6465
|
+
if (t.status !== "In Review") return null;
|
|
6466
|
+
if (t.reviewerId) return null;
|
|
6467
|
+
tasks[idx] = { ...t, reviewerId };
|
|
6468
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
6469
|
+
return tasks[idx];
|
|
6470
|
+
}
|
|
6381
6471
|
async recordTransition(_taskId, _fromStatus, _toStatus, _changedBy) {
|
|
6382
6472
|
}
|
|
6383
6473
|
// --- Build Reports ---
|
|
@@ -6817,6 +6907,8 @@ ${footer}`);
|
|
|
6817
6907
|
- **source:** ${full.source}
|
|
6818
6908
|
` + (full.sourceRef ? `- **sourceRef:** ${full.sourceRef}
|
|
6819
6909
|
` : "") + (full.detail ? `- **detail:** ${full.detail}
|
|
6910
|
+
` : "") + (full.evidenceRef ? `- **evidenceRef:** ${full.evidenceRef}
|
|
6911
|
+
` : "") + (full.metricDelta ? `- **metricDelta:** ${JSON.stringify(full.metricDelta)}
|
|
6820
6912
|
` : "") + `- **createdAt:** ${full.createdAt}
|
|
6821
6913
|
|
|
6822
6914
|
---
|
|
@@ -6850,8 +6942,18 @@ ${footer}`);
|
|
|
6850
6942
|
const sourceMatch = block.match(/\*\*source:\*\*\s+(.+)/);
|
|
6851
6943
|
const sourceRefMatch = block.match(/\*\*sourceRef:\*\*\s+(.+)/);
|
|
6852
6944
|
const detailMatch = block.match(/\*\*detail:\*\*\s+(.+)/);
|
|
6945
|
+
const evidenceRefMatch = block.match(/\*\*evidenceRef:\*\*\s+(.+)/);
|
|
6946
|
+
const metricDeltaMatch = block.match(/\*\*metricDelta:\*\*\s+(.+)/);
|
|
6853
6947
|
const createdAtMatch = block.match(/\*\*createdAt:\*\*\s+(.+)/);
|
|
6854
6948
|
if (!idMatch || !sourceMatch || !createdAtMatch) continue;
|
|
6949
|
+
let metricDelta;
|
|
6950
|
+
if (metricDeltaMatch?.[1]) {
|
|
6951
|
+
try {
|
|
6952
|
+
metricDelta = JSON.parse(metricDeltaMatch[1].trim());
|
|
6953
|
+
} catch {
|
|
6954
|
+
metricDelta = null;
|
|
6955
|
+
}
|
|
6956
|
+
}
|
|
6855
6957
|
events.push({
|
|
6856
6958
|
id: idMatch[1].trim(),
|
|
6857
6959
|
decisionId: headingMatch[1],
|
|
@@ -6860,6 +6962,8 @@ ${footer}`);
|
|
|
6860
6962
|
source: sourceMatch[1].trim(),
|
|
6861
6963
|
sourceRef: sourceRefMatch?.[1]?.trim(),
|
|
6862
6964
|
detail: detailMatch?.[1]?.trim(),
|
|
6965
|
+
evidenceRef: evidenceRefMatch?.[1]?.trim(),
|
|
6966
|
+
metricDelta,
|
|
6863
6967
|
createdAt: createdAtMatch[1].trim()
|
|
6864
6968
|
});
|
|
6865
6969
|
}
|
|
@@ -7667,8 +7771,18 @@ function formatDetailedTask(t) {
|
|
|
7667
7771
|
Reviewed: ${t.reviewed}${t.dependsOn ? ` | Depends on: ${t.dependsOn}` : ""}${hasHandoff ? " | Has BUILD HANDOFF: yes" : ""}${t.docRef ? ` | Doc ref: ${t.docRef}` : ""}${t.opportunity ? ` | Opportunity: ${t.opportunity}` : ""}${notes ? `
|
|
7668
7772
|
Notes: ${notes}` : ""}`;
|
|
7669
7773
|
}
|
|
7670
|
-
|
|
7671
|
-
|
|
7774
|
+
var PRIORITY_RANK = { "P0 Critical": 0, "P1 High": 1, "P2 Medium": 2, "P3 Low": 3 };
|
|
7775
|
+
function formatDeferredForPlan(deferred) {
|
|
7776
|
+
if (deferred.length === 0) return "";
|
|
7777
|
+
const sorted = [...deferred].sort(
|
|
7778
|
+
(a, b2) => (PRIORITY_RANK[a.priority] ?? 9) - (PRIORITY_RANK[b2.priority] ?? 9)
|
|
7779
|
+
);
|
|
7780
|
+
return `**Deferred (${deferred.length} parked \u2014 re-triageable, NOT active candidates):**
|
|
7781
|
+
These were parked, not deleted. Do NOT auto-schedule them. If a task's blocker has cleared or it now fits this cycle's strategic theme, un-defer it with a \`boardCorrections\` "promote" entry; otherwise leave it parked.
|
|
7782
|
+
` + sorted.map(formatCompactTask).join("\n");
|
|
7783
|
+
}
|
|
7784
|
+
function formatBoardForPlan(tasks, filters, currentCycle, deferredTasks = []) {
|
|
7785
|
+
if (tasks.length === 0 && deferredTasks.length === 0) return "No tasks on the board.";
|
|
7672
7786
|
let active = tasks.filter((t) => !PLAN_EXCLUDED_STATUSES.has(t.status));
|
|
7673
7787
|
const excludedCount = tasks.length - active.length;
|
|
7674
7788
|
if (filters) {
|
|
@@ -7677,13 +7791,24 @@ function formatBoardForPlan(tasks, filters, currentCycle) {
|
|
|
7677
7791
|
if (filters.epic) active = active.filter((t) => t.epic === filters.epic);
|
|
7678
7792
|
if (filters.priority) active = active.filter((t) => t.priority === filters.priority);
|
|
7679
7793
|
}
|
|
7794
|
+
let deferred = deferredTasks.filter((t) => t.status === "Deferred");
|
|
7795
|
+
if (filters) {
|
|
7796
|
+
if (filters.phase) deferred = deferred.filter((t) => t.phase === filters.phase);
|
|
7797
|
+
if (filters.module) deferred = deferred.filter((t) => t.module === filters.module);
|
|
7798
|
+
if (filters.epic) deferred = deferred.filter((t) => t.epic === filters.epic);
|
|
7799
|
+
if (filters.priority) deferred = deferred.filter((t) => t.priority === filters.priority);
|
|
7800
|
+
}
|
|
7801
|
+
const deferredSection = formatDeferredForPlan(deferred);
|
|
7680
7802
|
const userFilteredCount = tasks.length - excludedCount - active.length;
|
|
7681
7803
|
const filterParts = [];
|
|
7682
7804
|
if (excludedCount > 0) filterParts.push(`${excludedCount} completed filtered`);
|
|
7683
7805
|
if (userFilteredCount > 0) filterParts.push(`${userFilteredCount} excluded by filters`);
|
|
7684
7806
|
const filterSuffix = filterParts.length > 0 ? filterParts.join(", ") : "";
|
|
7685
7807
|
if (active.length === 0) {
|
|
7686
|
-
|
|
7808
|
+
const base = `No active tasks on the board (${filterSuffix || "all filtered"}).`;
|
|
7809
|
+
return deferredSection ? `${base}
|
|
7810
|
+
|
|
7811
|
+
${deferredSection}` : base;
|
|
7687
7812
|
}
|
|
7688
7813
|
const byCounts = (statuses) => statuses.map((s) => {
|
|
7689
7814
|
const n = active.filter((t) => t.status === s).length;
|
|
@@ -7692,9 +7817,12 @@ function formatBoardForPlan(tasks, filters, currentCycle) {
|
|
|
7692
7817
|
const summary = `Board: ${active.length} active tasks (${byCounts(["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Blocked"])})` + (filterSuffix ? ` \u2014 ${filterSuffix}` : "");
|
|
7693
7818
|
if (currentCycle === void 0) {
|
|
7694
7819
|
const formatted = active.map(formatDetailedTask).join("\n\n");
|
|
7695
|
-
|
|
7820
|
+
const body = `${summary}
|
|
7696
7821
|
|
|
7697
7822
|
${formatted}`;
|
|
7823
|
+
return deferredSection ? `${body}
|
|
7824
|
+
|
|
7825
|
+
${deferredSection}` : body;
|
|
7698
7826
|
}
|
|
7699
7827
|
const recent = [];
|
|
7700
7828
|
const stable = [];
|
|
@@ -7715,6 +7843,7 @@ ${formatted}`;
|
|
|
7715
7843
|
` + stable.map(formatCompactTask).join("\n")
|
|
7716
7844
|
);
|
|
7717
7845
|
}
|
|
7846
|
+
if (deferredSection) sections.push(deferredSection);
|
|
7718
7847
|
return sections.join("\n\n");
|
|
7719
7848
|
}
|
|
7720
7849
|
function formatCandidateTaskFullNotes(tasks) {
|
|
@@ -8166,11 +8295,19 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
|
|
|
8166
8295
|
|
|
8167
8296
|
2. **North Star** \u2014 Propose a one-sentence North Star statement, a success metric, and a key metric.
|
|
8168
8297
|
|
|
8169
|
-
3. **Initial Board** \u2014 Generate 3-5 tasks based on the project's actual tech stack and goals
|
|
8298
|
+
3. **Initial Board** \u2014 Generate 3-5 tasks based on the project's actual tech stack and goals.
|
|
8299
|
+
|
|
8300
|
+
**Sequencing principle \u2014 local-first / thinnest-vertical-slice-first (AD-51).** Plan the way the everyday builder gets value, NOT the way a backend team scaffolds. Order the first cycle so the user SEES the core loop working as fast as possible:
|
|
8301
|
+
1. **Thinnest visible vertical slice first** \u2014 the core loop on screen, running on local / mock / hardcoded data. Cycle 1 should make the thing actually work in front of the user.
|
|
8302
|
+
2. **Persistence / real data model** \u2014 only after the slice exists.
|
|
8303
|
+
3. **Auth** \u2014 later, and **NEVER third-party OAuth (Google/GitHub/etc.) as an opening task.** Start with no-auth or the simplest local auth.
|
|
8304
|
+
4. **RLS / cloud hardening / multi-tenant security** \u2014 last. These are not cycle-1 work for a new project.
|
|
8305
|
+
Defer infrastructure (cloud DB schema, RLS, OAuth providers, large data seeds) until a working slice exists. A first cycle that opens with "Supabase schema + RLS + third-party OAuth + a big data seed" is the exact anti-pattern this rule exists to prevent.
|
|
8306
|
+
|
|
8170
8307
|
- Infer the project type from the brief/description (CLI, web app, mobile app, API, library, game, data pipeline, etc.)
|
|
8171
|
-
- Task 1:
|
|
8172
|
-
- Task 2:
|
|
8173
|
-
- Tasks 3-5:
|
|
8308
|
+
- Task 1: The thinnest vertical slice that puts the core loop in front of the user (local/mock data is fine \u2014 and preferred \u2014 at this stage). NOT infra scaffolding unless the project literally has no runnable surface without it.
|
|
8309
|
+
- Task 2: The next slice, or the minimal persistence the core loop needs \u2014 only once the slice works.
|
|
8310
|
+
- Tasks 3-5: Further value-demonstrating slices, broken into small steps appropriate for the project type. Keep auth, cloud, and hardening OUT of the opening cycle unless the brief makes them the literal core product.
|
|
8174
8311
|
- Do NOT assume web-app patterns (routes, pages, components) unless the brief explicitly describes a web application
|
|
8175
8312
|
- All tasks: status Backlog, priority P1-P2, reviewed true, phase "Phase 1"
|
|
8176
8313
|
|
|
@@ -8333,7 +8470,7 @@ Standard planning cycle with full board review.
|
|
|
8333
8470
|
}
|
|
8334
8471
|
parts.push(`
|
|
8335
8472
|
6. **Maturity Gate** \u2014 Before scheduling any task, check whether the project is ready for it:
|
|
8336
|
-
- **Cycle number as signal:** A Cycle 3 project should not be scheduling OAuth, billing, or
|
|
8473
|
+
- **Cycle number as signal (local-first sequencing, AD-51):** A Cycle 1-3 project should not be scheduling OAuth, billing, analytics, or RLS/cloud-hardening tasks. Early cycles sequence local-first / thinnest-vertical-slice-first: get the core loop working on local/mock data, THEN persistence, THEN auth (never third-party OAuth as an opening task), and RLS/cloud hardening LAST. Defer infrastructure until a working slice exists \u2014 plan the way the everyday builder gets value, not the way a backend team scaffolds.
|
|
8337
8474
|
- **Phase prerequisites:** If the board has phases, tasks from later phases should only be scheduled when earlier phases have completed tasks (check Done count per phase). A task in "Phase 4: Monetisation" is premature if Phase 2 tasks are still in Backlog.
|
|
8338
8475
|
- **Dependency chain:** If a task's \`dependsOn\` references incomplete tasks, it cannot be scheduled regardless of priority.
|
|
8339
8476
|
- **Task maturity:** Tasks with \`maturity: "raw"\` are unscoped ideas from the idea tool. The planner IS the scoping mechanism \u2014 scope them as part of planning. For raw tasks selected for a cycle: (a) derive clear scope, acceptance criteria, and effort from the title, notes, and project context, (b) upgrade them to \`maturity: "investigated"\` via a \`boardCorrections\` entry, and (c) generate a BUILD HANDOFF as normal. For research-type raw tasks, scope the handoff as an investigation task \u2014 the deliverable is findings + follow-up backlog tasks, not code. Only leave a raw task unscheduled if you genuinely cannot derive scope from the available context \u2014 note why in the cycle log. Tasks with \`maturity: "ready"\` or no maturity field are considered cycle-ready. Tasks with \`maturity: "investigated"\` have been scoped but may still need refinement \u2014 schedule them if priority warrants it.
|
|
@@ -8407,7 +8544,7 @@ ${AD_REJECTION_RULES}
|
|
|
8407
8544
|
- **Architecture Notes:** If a pattern was established that needs follow-up (e.g. "shared service layer created, MCP migration needed"), propose the follow-up.
|
|
8408
8545
|
- **Strategy gaps:** If an Active Decision has no board tasks supporting it, propose one.
|
|
8409
8546
|
- **Dogfood observations:** Unactioned dogfood entries span FOUR categories (friction, methodology, signal, commercial) \u2014 consider ALL of them, not just friction. If an entry (with ID) maps to no existing task, propose one, matching task type to category: friction/signal \u2192 fix or improvement; methodology \u2192 process/tooling change; commercial \u2192 GTM/positioning. **CRITICAL: Include \`dogfood:<uuid>\` in the new task's \`notes\` field** (e.g. \`"notes": "dogfood:abc12345-..."\`). This links the task to the observation so the pipeline can track what was actioned. Without this annotation, the observation stays unactioned forever.
|
|
8410
|
-
Create new tasks via the \`newTasks\` array in Part 2. Use \`new-N\` IDs in \`cycleHandoffs\` to reference them. **
|
|
8547
|
+
Create new tasks via the \`newTasks\` array in Part 2. Use \`new-N\` IDs in \`cycleHandoffs\` to reference them. **New-task cap \u2014 scale it to backlog depth, do NOT hard-cap at 3:** when 5+ unblocked backlog candidates already exist, limit new tasks to 3 (there is plenty to select from, so prevent backlog bloat). When the backlog is thin (fewer than 5 unblocked candidates), create as many as needed to bring the cycle into the healthy 5-8 range \u2014 roughly \`6 \u2212 unblocked_candidates\` additional new tasks, derived from the brief, roadmap phases, and recent build reports \u2014 up to an absolute ceiling of **8 new tasks**. This removes the contradiction with the Cycle-sizing rule above (a healthy cycle is 6-10 tasks; <5 needs justification), which the old flat cap of 3 violated whenever the backlog was thin. Never invent filler to hit a number: every new task must trace to a real discovered issue, dogfood entry, roadmap phase, or brief item.
|
|
8411
8548
|
**\u26A0\uFE0F DUPLICATE CHECK:** Before adding a task to \`newTasks\`, scan the Cycle Board above for any existing task with the same or very similar title/scope. If a matching task already exists (even with slightly different wording), do NOT create a duplicate \u2014 reference the existing task ID instead. The board already contains all active tasks; re-creating them wastes IDs and bloats the board.
|
|
8412
8549
|
**\u26A0\uFE0F ALREADY-BUILT CHECK:** Before creating a task, check the recent build reports and cycle log for evidence that this capability was already shipped. If a recent build report shows this feature was completed (even under a different task name), do NOT create a new task for it. This is especially important for UI features, data models, and integrations that may already exist.`);
|
|
8413
8550
|
parts.push(PLAN_FRAGMENT_PRODUCT_BRIEF);
|
|
@@ -8485,6 +8622,9 @@ function buildPlanUserMessage(ctx) {
|
|
|
8485
8622
|
""
|
|
8486
8623
|
);
|
|
8487
8624
|
}
|
|
8625
|
+
if (ctx.siblingRepoWarning) {
|
|
8626
|
+
parts.push("", ctx.siblingRepoWarning, "");
|
|
8627
|
+
}
|
|
8488
8628
|
parts.push("", "---", "", "## PROJECT CONTEXT", "");
|
|
8489
8629
|
if (ctx.contextTier) {
|
|
8490
8630
|
parts.push(`**Context tier:** ${ctx.contextTier}`, "");
|
|
@@ -8840,7 +8980,9 @@ After your natural language output, include this EXACT format on its own line:
|
|
|
8840
8980
|
{
|
|
8841
8981
|
"id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
|
|
8842
8982
|
"action": "confidence_change | modify | resolve | supersede | new | delete",
|
|
8843
|
-
"body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)"
|
|
8983
|
+
"body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
|
|
8984
|
+
"evidenceRef": "string (optional) \u2014 for DELIBERATE decisions (modify / resolve / delete = validate/modify/invalidate), a pointer to the evidence that justified the change: a doc path (docs/research/foo.md), a build-report id, or a metric name. Builds the decision->outcome ledger. Omit if no concrete evidence.",
|
|
8985
|
+
"metricDelta": "object (optional) \u2014 { "metric": string, "before": number, "after": number, "delta": number } \u2014 which metric moved and by how much. Use a REAL metric name from cycle_metrics_snapshots where possible (e.g. scope_accuracy, velocity, est_actual_drift). Omit if no metric moved."
|
|
8844
8986
|
}
|
|
8845
8987
|
],
|
|
8846
8988
|
"decisionScores": [
|
|
@@ -8900,6 +9042,8 @@ The JSON must be valid. Use null for optional fields that don't apply.
|
|
|
8900
9042
|
For activeDecisionUpdates, the body field must be the COMPLETE replacement text for the AD block (including the ### heading line).
|
|
8901
9043
|
Only include ADs that need changes \u2014 omit unchanged ADs.${compressionNote}
|
|
8902
9044
|
|
|
9045
|
+
**Decision-outcome ledger (task-2168):** For DELIBERATE decisions \u2014 \`modify\`, \`resolve\`, or \`delete\` (i.e. you validated, modified, or invalidated an AD based on what actually happened) \u2014 include \`evidenceRef\` and/or \`metricDelta\` so the decision becomes a queryable ledger entry rather than freetext. \`evidenceRef\` points at WHAT justified the change (a doc path, a build-report id, or a metric name). \`metricDelta\` records WHICH metric moved (prefer a real metric from cycle_metrics_snapshots) and its before->after. This is **guidance, not a hard requirement** \u2014 if a deliberate change genuinely has no concrete evidence, omit both and proceed; the apply will record the event with a non-blocking warning.
|
|
9046
|
+
|
|
8903
9047
|
## PERSISTENCE RULES \u2014 READ THIS CAREFULLY
|
|
8904
9048
|
|
|
8905
9049
|
Everything in Part 1 (natural language) is **display-only**. Part 2 (structured JSON) is what gets written to files.
|
|
@@ -9065,7 +9209,9 @@ After your natural language output, include this EXACT format on its own line:
|
|
|
9065
9209
|
{
|
|
9066
9210
|
"id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
|
|
9067
9211
|
"action": "confidence_change | modify | resolve | supersede | new | delete",
|
|
9068
|
-
"body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)"
|
|
9212
|
+
"body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
|
|
9213
|
+
"evidenceRef": "string (optional) \u2014 for DELIBERATE changes (modify / resolve / delete), a pointer to the justifying evidence: a doc path, a build-report id, or a metric name. Builds the decision->outcome ledger. Omit if none.",
|
|
9214
|
+
"metricDelta": "object (optional) \u2014 { "metric": string, "before": number, "after": number, "delta": number } \u2014 which metric moved. Prefer a real metric name from cycle_metrics_snapshots. Omit if none."
|
|
9069
9215
|
}
|
|
9070
9216
|
],
|
|
9071
9217
|
"phaseUpdates": [
|
|
@@ -9382,6 +9528,37 @@ Return a JSON array of 3-10 tasks. Each task must have:
|
|
|
9382
9528
|
- Use the full complexity range: XS (config/one-liner), Small (one file), Medium (2-5 files), Large (cross-module), XL (architectural)
|
|
9383
9529
|
- Tasks should be specific enough to execute without further investigation
|
|
9384
9530
|
- Maximum 10 tasks \u2014 fewer is better if the codebase is well-maintained`;
|
|
9531
|
+
var VISION_TASKS_SYSTEM = `You are a product engineer turning a project VISION into a starter backlog for a brand-new project (no code exists yet). The user has described what they want to build; your job is to make that vision legible as concrete, buildable tasks.
|
|
9532
|
+
|
|
9533
|
+
IMPORTANT: You are running as a non-interactive API call. Do NOT ask questions. Produce tasks directly.
|
|
9534
|
+
|
|
9535
|
+
## OUTPUT FORMAT
|
|
9536
|
+
|
|
9537
|
+
Return a JSON array of 15-20 tasks. Each task must have:
|
|
9538
|
+
- "title": Clear, actionable task title (start with a verb)
|
|
9539
|
+
- "priority": "P0 Critical", "P1 High", "P2 Medium", or "P3 Low"
|
|
9540
|
+
- "complexity": "XS", "Small", "Medium", "Large", or "XL"
|
|
9541
|
+
- "module": A module name inferred from the vision (e.g. "Core", "Auth", "Frontend", "API", "Payments")
|
|
9542
|
+
- "phase": A phase name ("Phase 1" for the first shippable slice, "Phase 2" for what follows, etc.)
|
|
9543
|
+
- "notes": 1-2 sentences tying this task to the user's stated vision
|
|
9544
|
+
|
|
9545
|
+
## GUIDELINES
|
|
9546
|
+
|
|
9547
|
+
- Cover the VISION, not a generic app skeleton. Every task must trace to something the user actually described.
|
|
9548
|
+
- Sequence local-first / thinnest-vertical-slice-first (AD-51): the first few P0/P1 tasks should be the smallest path to a visibly working thing, NOT infrastructure scaffolding (no "set up CI", "configure auth provider", "design database schema" as opening tasks). Plan the way the user gets value, not the way a backend team scaffolds.
|
|
9549
|
+
- Mix: the first shippable feature slice (Phase 1), then the next features, then supporting/foundational work behind them.
|
|
9550
|
+
- 15-20 is a TARGET BAND for good vision coverage, NOT a quota. If the vision is genuinely small, produce fewer high-quality tasks. NEVER pad with filler ("add tests", "write docs", "refactor") to hit a number.
|
|
9551
|
+
- Use the full complexity range. Keep titles concrete enough to execute without re-deriving the vision.
|
|
9552
|
+
- Do NOT add PAPI-setup tasks \u2014 those are handled by the setup flow.`;
|
|
9553
|
+
function buildVisionTasksPrompt(inputs) {
|
|
9554
|
+
const line = (label, val) => val?.trim() ? `**${label}:** ${val.trim()}
|
|
9555
|
+
` : "";
|
|
9556
|
+
return `Turn this project vision into a 15-20 item starter backlog.
|
|
9557
|
+
|
|
9558
|
+
**Project:** ${inputs.projectName}
|
|
9559
|
+
${line("What it is", inputs.description)}${line("Target users", inputs.targetUsers)}${line("Problems it solves", inputs.problems)}${line("Project type", inputs.projectType)}
|
|
9560
|
+
You are ALSO generating this project's Product Brief in this same setup round \u2014 use that fuller vision as your primary source. Return a JSON array of 15-20 tasks (coverage over count; do not pad) that make this vision a buildable backlog, thinnest-shippable-slice first.`;
|
|
9561
|
+
}
|
|
9385
9562
|
function buildInitialTasksPrompt(inputs) {
|
|
9386
9563
|
const description = inputs.description?.trim() ? `**Description:** ${inputs.description}
|
|
9387
9564
|
` : `**Description:** (not provided \u2014 derive from the codebase analysis below)
|
|
@@ -9533,8 +9710,8 @@ function oneLine(ad) {
|
|
|
9533
9710
|
}
|
|
9534
9711
|
function formatHierarchy(horizons, stages) {
|
|
9535
9712
|
if (!horizons.length && !stages.length) return "";
|
|
9536
|
-
const activeHorizon = horizons.find((h) => h.status === "
|
|
9537
|
-
const activeStage = stages.find((s) => s.status === "
|
|
9713
|
+
const activeHorizon = horizons.find((h) => h.status === "In Progress") ?? horizons[0];
|
|
9714
|
+
const activeStage = stages.find((s) => s.status === "In Progress") ?? stages[0];
|
|
9538
9715
|
const parts = [];
|
|
9539
9716
|
if (activeHorizon) parts.push(activeHorizon.slug || activeHorizon.label);
|
|
9540
9717
|
if (activeStage) parts.push(activeStage.slug || activeStage.label);
|
|
@@ -9557,7 +9734,7 @@ function validateHandoffScope(handoff) {
|
|
|
9557
9734
|
if (!isMeaningful(handoff.scopeBoundary)) invalid.push("scopeBoundary");
|
|
9558
9735
|
return invalid;
|
|
9559
9736
|
}
|
|
9560
|
-
async function prepareHandoffs(adapter2, _config, taskIds) {
|
|
9737
|
+
async function prepareHandoffs(adapter2, _config, taskIds, force = false) {
|
|
9561
9738
|
const timer2 = startTimer();
|
|
9562
9739
|
const cycles = await adapter2.readCycles();
|
|
9563
9740
|
const activeCycle = cycles.find((c) => c.status === "active");
|
|
@@ -9571,10 +9748,10 @@ async function prepareHandoffs(adapter2, _config, taskIds) {
|
|
|
9571
9748
|
const idSet = new Set(taskIds);
|
|
9572
9749
|
cycleTasks = cycleTasks.filter((t) => idSet.has(t.id));
|
|
9573
9750
|
}
|
|
9574
|
-
const tasksNeedingHandoffs = cycleTasks.filter((t) => !t.buildHandoff);
|
|
9751
|
+
const tasksNeedingHandoffs = force ? cycleTasks : cycleTasks.filter((t) => !t.buildHandoff);
|
|
9575
9752
|
if (tasksNeedingHandoffs.length === 0) {
|
|
9576
9753
|
throw new Error(
|
|
9577
|
-
taskIds?.length ? `All specified tasks already have BUILD HANDOFFs. Nothing to generate.` : `All ${cycleTasks.length} cycle task(s) already have BUILD HANDOFFs. Nothing to generate.`
|
|
9754
|
+
force ? taskIds?.length ? `No matching cycle task(s) found to regenerate. Check the task IDs are in the active cycle.` : `No cycle task(s) found to regenerate.` : taskIds?.length ? `All specified tasks already have BUILD HANDOFFs. Nothing to generate. Pass force: true to regenerate.` : `All ${cycleTasks.length} cycle task(s) already have BUILD HANDOFFs. Nothing to generate. Pass force: true to regenerate.`
|
|
9578
9755
|
);
|
|
9579
9756
|
}
|
|
9580
9757
|
const [decisions, reports, brief] = await Promise.all([
|
|
@@ -9605,7 +9782,7 @@ async function prepareHandoffs(adapter2, _config, taskIds) {
|
|
|
9605
9782
|
taskCount: tasksNeedingHandoffs.length
|
|
9606
9783
|
};
|
|
9607
9784
|
}
|
|
9608
|
-
async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber) {
|
|
9785
|
+
async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false) {
|
|
9609
9786
|
const timer2 = startTimer();
|
|
9610
9787
|
const { data } = parseStructuredOutput(rawLlmOutput);
|
|
9611
9788
|
if (!data) {
|
|
@@ -9629,7 +9806,7 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber) {
|
|
|
9629
9806
|
const warnings = [];
|
|
9630
9807
|
for (const handoff of handoffs) {
|
|
9631
9808
|
try {
|
|
9632
|
-
if (existingHandoffSet.has(handoff.taskId)) {
|
|
9809
|
+
if (!force && existingHandoffSet.has(handoff.taskId)) {
|
|
9633
9810
|
console.error(`[handoff] skipping ${handoff.taskId} \u2014 already has handoff`);
|
|
9634
9811
|
skipped++;
|
|
9635
9812
|
continue;
|
|
@@ -10005,7 +10182,72 @@ async function resolveVisibilityFromDocs(adapter2, docRefs) {
|
|
|
10005
10182
|
};
|
|
10006
10183
|
}
|
|
10007
10184
|
|
|
10185
|
+
// src/lib/owner-identity.ts
|
|
10186
|
+
function isProjectOwner(callerUserId, ownerUserId) {
|
|
10187
|
+
if (!callerUserId || !ownerUserId) return false;
|
|
10188
|
+
const caller = callerUserId.trim().toLowerCase();
|
|
10189
|
+
const owner = ownerUserId.trim().toLowerCase();
|
|
10190
|
+
if (caller.length === 0 || owner.length === 0) return false;
|
|
10191
|
+
return caller === owner;
|
|
10192
|
+
}
|
|
10193
|
+
async function resolveOwnerGate(adapter2, config2) {
|
|
10194
|
+
if (typeof adapter2.getOwnerIdentity === "function") {
|
|
10195
|
+
try {
|
|
10196
|
+
const identity = await adapter2.getOwnerIdentity();
|
|
10197
|
+
const callerUserId = identity.callerUserId ?? config2.userId ?? null;
|
|
10198
|
+
return {
|
|
10199
|
+
enforced: true,
|
|
10200
|
+
callerIsOwner: isProjectOwner(callerUserId, identity.ownerUserId),
|
|
10201
|
+
callerUserId,
|
|
10202
|
+
ownerUserId: identity.ownerUserId
|
|
10203
|
+
};
|
|
10204
|
+
} catch (err) {
|
|
10205
|
+
return {
|
|
10206
|
+
enforced: true,
|
|
10207
|
+
callerIsOwner: false,
|
|
10208
|
+
callerUserId: config2.userId ?? null,
|
|
10209
|
+
ownerUserId: null,
|
|
10210
|
+
resolutionError: err instanceof Error ? err.message : String(err)
|
|
10211
|
+
};
|
|
10212
|
+
}
|
|
10213
|
+
}
|
|
10214
|
+
if (typeof adapter2.getProjectOwnerUserId === "function") {
|
|
10215
|
+
let ownerUserId = null;
|
|
10216
|
+
let resolutionError;
|
|
10217
|
+
try {
|
|
10218
|
+
ownerUserId = await adapter2.getProjectOwnerUserId();
|
|
10219
|
+
} catch (err) {
|
|
10220
|
+
resolutionError = err instanceof Error ? err.message : String(err);
|
|
10221
|
+
}
|
|
10222
|
+
const callerUserId = config2.userId ?? null;
|
|
10223
|
+
return {
|
|
10224
|
+
enforced: true,
|
|
10225
|
+
callerIsOwner: isProjectOwner(callerUserId, ownerUserId),
|
|
10226
|
+
callerUserId,
|
|
10227
|
+
ownerUserId,
|
|
10228
|
+
...resolutionError !== void 0 ? { resolutionError } : {}
|
|
10229
|
+
};
|
|
10230
|
+
}
|
|
10231
|
+
return {
|
|
10232
|
+
enforced: false,
|
|
10233
|
+
callerIsOwner: true,
|
|
10234
|
+
callerUserId: config2.userId ?? null,
|
|
10235
|
+
ownerUserId: null
|
|
10236
|
+
};
|
|
10237
|
+
}
|
|
10238
|
+
|
|
10008
10239
|
// src/services/plan.ts
|
|
10240
|
+
async function resolvePlanScope(adapter2, config2) {
|
|
10241
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
10242
|
+
return { callerUserId: gate.callerUserId, ownerUserId: gate.ownerUserId, callerIsOwner: gate.callerIsOwner };
|
|
10243
|
+
}
|
|
10244
|
+
function filterToPersonalBacklog(tasks, scope) {
|
|
10245
|
+
if (!scope.callerUserId) return tasks;
|
|
10246
|
+
if (scope.callerIsOwner) {
|
|
10247
|
+
return tasks.filter((t) => !t.assigneeId || t.assigneeId === scope.callerUserId);
|
|
10248
|
+
}
|
|
10249
|
+
return tasks.filter((t) => t.assigneeId === scope.callerUserId);
|
|
10250
|
+
}
|
|
10009
10251
|
function determineContextTier(cycleCount) {
|
|
10010
10252
|
if (cycleCount <= 5) return 1;
|
|
10011
10253
|
if (cycleCount <= 20) return 2;
|
|
@@ -10294,8 +10536,8 @@ var BRIEF_SECTIONS = [
|
|
|
10294
10536
|
}
|
|
10295
10537
|
];
|
|
10296
10538
|
function assessBriefThinness(brief) {
|
|
10297
|
-
const
|
|
10298
|
-
const briefWithoutTemplate = brief.replace(
|
|
10539
|
+
const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
|
|
10540
|
+
const briefWithoutTemplate = brief.replace(TEMPLATE_MARKER2, "");
|
|
10299
10541
|
const populated = [];
|
|
10300
10542
|
const missing = [];
|
|
10301
10543
|
for (const section of BRIEF_SECTIONS) {
|
|
@@ -10434,12 +10676,34 @@ async function readDogfoodEntries(projectRoot, count = 5, adapter2) {
|
|
|
10434
10676
|
return void 0;
|
|
10435
10677
|
}
|
|
10436
10678
|
}
|
|
10679
|
+
function formatSiblingRepoWarning(siblings) {
|
|
10680
|
+
const lines = siblings.slice(0, 20).map(
|
|
10681
|
+
(s) => `- ${s.displayId} [${s.sourceProjectName}] (${s.status}${s.epic ? `, ${s.epic}` : ""}): ${s.title}`
|
|
10682
|
+
);
|
|
10683
|
+
const more = siblings.length > 20 ? `
|
|
10684
|
+
\u2026and ${siblings.length - 20} more` : "";
|
|
10685
|
+
return [
|
|
10686
|
+
"## \u26A0\uFE0F SHARED-REPO SIBLING WORK \u2014 CHECK BEFORE SCHEDULING",
|
|
10687
|
+
"",
|
|
10688
|
+
"Other PAPI projects share THIS repository and have the tasks below IN FLIGHT (backlog / in progress / in review). Shared-repo work (multi-user, RLS, auth, common codebase) may already be built or underway in a sibling project. Do NOT schedule a task that duplicates one below \u2014 skip it or note the overlap in cycleLogNotes instead of re-building. (Root cause of the C292 incident: the planner re-scheduled already-merged multi-user work, costing a build + a prod DB revert.)",
|
|
10689
|
+
"",
|
|
10690
|
+
...lines,
|
|
10691
|
+
more
|
|
10692
|
+
].filter(Boolean).join("\n");
|
|
10693
|
+
}
|
|
10437
10694
|
async function assembleContext(adapter2, mode, _config, filters, focus) {
|
|
10438
10695
|
const totalTimer = startTimer();
|
|
10439
10696
|
const timings = {};
|
|
10697
|
+
const planScope = await resolvePlanScope(adapter2, _config);
|
|
10440
10698
|
let t = startTimer();
|
|
10441
10699
|
const health = await adapter2.getCycleHealth();
|
|
10442
10700
|
timings["getCycleHealth"] = t();
|
|
10701
|
+
let siblingRepoWarning;
|
|
10702
|
+
try {
|
|
10703
|
+
const siblings = await adapter2.getSiblingRepoTasks?.() ?? [];
|
|
10704
|
+
if (siblings.length > 0) siblingRepoWarning = formatSiblingRepoWarning(siblings);
|
|
10705
|
+
} catch {
|
|
10706
|
+
}
|
|
10443
10707
|
t = startTimer();
|
|
10444
10708
|
const [rawProductBrief, currentNorthStar] = await Promise.all([
|
|
10445
10709
|
adapter2.readProductBrief(),
|
|
@@ -10564,9 +10828,10 @@ ${lines.join("\n")}`;
|
|
|
10564
10828
|
const doneIds = doneTasksResult.status === "fulfilled" ? new Set(doneTasksResult.value.map((t2) => t2.displayId)) : void 0;
|
|
10565
10829
|
carryForwardStalenessLean = computeCarryForwardStaleness(cycleLogResult.value, doneIds);
|
|
10566
10830
|
}
|
|
10831
|
+
const candidateTasksLean = preAssignedResult.status === "fulfilled" ? filterToPersonalBacklog(preAssignedResult.value, planScope) : [];
|
|
10567
10832
|
let preAssignedTextLean;
|
|
10568
10833
|
if (preAssignedResult.status === "fulfilled") {
|
|
10569
|
-
const preAssigned2 =
|
|
10834
|
+
const preAssigned2 = candidateTasksLean.filter((t2) => t2.cycle === leanTargetCycle);
|
|
10570
10835
|
preAssignedTextLean = formatPreAssignedTasks(preAssigned2, leanTargetCycle);
|
|
10571
10836
|
}
|
|
10572
10837
|
let recentlyShippedLean;
|
|
@@ -10575,7 +10840,7 @@ ${lines.join("\n")}`;
|
|
|
10575
10840
|
}
|
|
10576
10841
|
let candidateTaskFullNotesLean;
|
|
10577
10842
|
if (preAssignedResult.status === "fulfilled") {
|
|
10578
|
-
candidateTaskFullNotesLean = formatCandidateTaskFullNotes(
|
|
10843
|
+
candidateTaskFullNotesLean = formatCandidateTaskFullNotes(candidateTasksLean);
|
|
10579
10844
|
}
|
|
10580
10845
|
logDataSourceSummary("plan (lean)", [
|
|
10581
10846
|
{ label: "cycleHealth", hasData: !!health },
|
|
@@ -10617,7 +10882,8 @@ ${lines.join("\n")}`;
|
|
|
10617
10882
|
recentlyShippedCapabilities: recentlyShippedLean,
|
|
10618
10883
|
strategyReviewCadence,
|
|
10619
10884
|
candidateTaskFullNotes: candidateTaskFullNotesLean,
|
|
10620
|
-
foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief)
|
|
10885
|
+
foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief),
|
|
10886
|
+
siblingRepoWarning
|
|
10621
10887
|
};
|
|
10622
10888
|
const { label: leanTierLabel } = applyContextTier(ctx2, health.totalCycles);
|
|
10623
10889
|
ctx2.contextTier = leanTierLabel;
|
|
@@ -10633,7 +10899,7 @@ ${lines.join("\n")}`;
|
|
|
10633
10899
|
return { context: ctx2, contextHashes: newHashes2 };
|
|
10634
10900
|
}
|
|
10635
10901
|
t = startTimer();
|
|
10636
|
-
const [decisions, reportsSinceCycle, log2, tasks, rawMetricsSnapshots, reviews, phases, dogfoodEntries, allBuildReports] = await Promise.all([
|
|
10902
|
+
const [decisions, reportsSinceCycle, log2, tasks, rawMetricsSnapshots, reviews, phases, dogfoodEntries, allBuildReports, deferredRaw] = await Promise.all([
|
|
10637
10903
|
adapter2.getActiveDecisions(),
|
|
10638
10904
|
adapter2.getBuildReportsSince(health.totalCycles ?? 0),
|
|
10639
10905
|
adapter2.getCycleLog(3),
|
|
@@ -10642,7 +10908,10 @@ ${lines.join("\n")}`;
|
|
|
10642
10908
|
adapter2.getRecentReviews(5),
|
|
10643
10909
|
adapter2.readPhases(),
|
|
10644
10910
|
readDogfoodEntries(_config.projectRoot, 5, adapter2),
|
|
10645
|
-
adapter2.getRecentBuildReports(50)
|
|
10911
|
+
adapter2.getRecentBuildReports(50),
|
|
10912
|
+
// task-2198: Deferred tasks queried SEPARATELY so they reach the planner as a
|
|
10913
|
+
// re-triageable set without ever entering the selectable candidate pool.
|
|
10914
|
+
adapter2.queryBoard({ status: ["Deferred"], contextTier: 2, compact: true })
|
|
10646
10915
|
]);
|
|
10647
10916
|
timings["fullQueries"] = t();
|
|
10648
10917
|
const reports = reportsSinceCycle.length > 0 ? reportsSinceCycle : allBuildReports.slice(0, 5);
|
|
@@ -10699,9 +10968,13 @@ ${lines.join("\n")}`;
|
|
|
10699
10968
|
const briefExcluded = strippedTasks.filter(
|
|
10700
10969
|
(t2) => t2.scopeClass === "brief" && !ACTIVE_STATUSES2.has(t2.status)
|
|
10701
10970
|
);
|
|
10702
|
-
const plannerTasks =
|
|
10703
|
-
|
|
10971
|
+
const plannerTasks = filterToPersonalBacklog(
|
|
10972
|
+
strippedTasks.filter(
|
|
10973
|
+
(t2) => (t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)) && (t2.scopeClass !== "brief" || ACTIVE_STATUSES2.has(t2.status))
|
|
10974
|
+
),
|
|
10975
|
+
planScope
|
|
10704
10976
|
);
|
|
10977
|
+
const deferredTasks = filterToPersonalBacklog(stripTasksForPlan(deferredRaw), planScope);
|
|
10705
10978
|
if (p3Excluded.length > 0) {
|
|
10706
10979
|
console.error(`[plan-perf] board tiering: excluded ${p3Excluded.length} P3 Low tasks from planner context`);
|
|
10707
10980
|
}
|
|
@@ -10771,7 +11044,7 @@ ${logLines}`);
|
|
|
10771
11044
|
activeDecisions: formatActiveDecisionsForPlan(decisions),
|
|
10772
11045
|
recentBuildReports: formatBuildReports(cappedReports),
|
|
10773
11046
|
cycleLog: formatCycleLog(log2),
|
|
10774
|
-
board: formatBoardForPlan(plannerTasks, filters, health.totalCycles),
|
|
11047
|
+
board: formatBoardForPlan(plannerTasks, filters, health.totalCycles, deferredTasks),
|
|
10775
11048
|
northStar,
|
|
10776
11049
|
methodologyMetrics: formatCycleMetrics(metricsSnapshots),
|
|
10777
11050
|
recentReviews: formatReviews(reviews),
|
|
@@ -10794,7 +11067,8 @@ ${logLines}`);
|
|
|
10794
11067
|
recentlyShippedCapabilities: formatRecentlyShippedCapabilities(reports),
|
|
10795
11068
|
strategyReviewCadence: strategyReviewCadenceFull,
|
|
10796
11069
|
candidateTaskFullNotes: formatCandidateTaskFullNotes(plannerTasks),
|
|
10797
|
-
foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief)
|
|
11070
|
+
foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief),
|
|
11071
|
+
siblingRepoWarning
|
|
10798
11072
|
};
|
|
10799
11073
|
const { label: fullTierLabel } = applyContextTier(ctx, health.totalCycles);
|
|
10800
11074
|
ctx.contextTier = fullTierLabel;
|
|
@@ -10860,7 +11134,10 @@ ${cleanContent}`;
|
|
|
10860
11134
|
goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
|
|
10861
11135
|
boardHealth: data.boardHealth || "",
|
|
10862
11136
|
taskIds: cycleTaskIds,
|
|
10863
|
-
contextHashes
|
|
11137
|
+
contextHashes,
|
|
11138
|
+
// task-2071 (MU-3): own the cycle by the planning member (pg defaults to the
|
|
11139
|
+
// project owner when unset, so owner-operated plans stay correct).
|
|
11140
|
+
...options.ownerUserId ? { userId: options.ownerUserId } : {}
|
|
10864
11141
|
};
|
|
10865
11142
|
let dedupedNewTasks = data.newTasks ?? [];
|
|
10866
11143
|
if (dedupedNewTasks.length > 0) {
|
|
@@ -10945,25 +11222,12 @@ ${cleanContent}`;
|
|
|
10945
11222
|
const writeBackMs = writeBackTimer();
|
|
10946
11223
|
console.error(`[plan-perf] transactionalWriteBack: total=${writeBackMs}ms`);
|
|
10947
11224
|
const verifyWarnings = [];
|
|
10948
|
-
|
|
10949
|
-
|
|
10950
|
-
|
|
10951
|
-
|
|
10952
|
-
|
|
10953
|
-
|
|
10954
|
-
if (!newCycle) {
|
|
10955
|
-
verifyWarnings.push(`Post-write verification FAILED: cycle ${newCycleNumber} entity not found after commit \u2014 data may not have persisted`);
|
|
10956
|
-
} else {
|
|
10957
|
-
const expectedTaskCount = data.cycleTaskIds?.length ?? data.cycleHandoffs?.length ?? 0;
|
|
10958
|
-
const actualCycleTasks = boardTasks.filter((t) => t.cycle === newCycleNumber).length;
|
|
10959
|
-
if (expectedTaskCount > 0 && actualCycleTasks === 0) {
|
|
10960
|
-
verifyWarnings.push(`Post-write verification FAILED: cycle ${newCycleNumber} exists but has 0 tasks assigned (expected ${expectedTaskCount}) \u2014 task cycle assignment may have failed`);
|
|
10961
|
-
} else if (expectedTaskCount > 0 && actualCycleTasks < expectedTaskCount) {
|
|
10962
|
-
verifyWarnings.push(`Post-write verification WARNING: cycle ${newCycleNumber} has ${actualCycleTasks} tasks but expected ${expectedTaskCount} \u2014 some task assignments may have failed`);
|
|
10963
|
-
}
|
|
10964
|
-
}
|
|
10965
|
-
} catch {
|
|
10966
|
-
verifyWarnings.push("Post-write verification: could not read cycles/tasks tables");
|
|
11225
|
+
const expectedNewTasks = dedupedNewTasks.length;
|
|
11226
|
+
const actualNewTasks = result.newTaskIdMap.size;
|
|
11227
|
+
if (expectedNewTasks > 0 && actualNewTasks < expectedNewTasks) {
|
|
11228
|
+
verifyWarnings.push(
|
|
11229
|
+
`Plan write-back created ${actualNewTasks} of ${expectedNewTasks} new tasks the transaction was asked to create \u2014 investigate the planWriteBack handler. (This count is returned by the committed transaction, not a board re-read, so it is not a hosted read-lag artifact.)`
|
|
11230
|
+
);
|
|
10967
11231
|
}
|
|
10968
11232
|
const allWarnings = [...result.warnings, ...verifyWarnings];
|
|
10969
11233
|
const handoffCount = data.cycleHandoffs?.length ?? 0;
|
|
@@ -11258,7 +11522,9 @@ ${cleanContent}`;
|
|
|
11258
11522
|
goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
|
|
11259
11523
|
boardHealth: data.boardHealth || "",
|
|
11260
11524
|
taskIds: cycleTaskIds,
|
|
11261
|
-
contextHashes
|
|
11525
|
+
contextHashes,
|
|
11526
|
+
// task-2071 (MU-3): own the cycle by the planning member.
|
|
11527
|
+
...options.ownerUserId ? { userId: options.ownerUserId } : {}
|
|
11262
11528
|
};
|
|
11263
11529
|
await adapter2.createCycle(cycle);
|
|
11264
11530
|
} catch (err) {
|
|
@@ -11300,7 +11566,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
|
|
|
11300
11566
|
for (const c of cycles) {
|
|
11301
11567
|
if (!newestByNumber.has(c.number)) newestByNumber.set(c.number, c);
|
|
11302
11568
|
}
|
|
11303
|
-
const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber);
|
|
11569
|
+
const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber).filter((c) => opts.userId == null || c.userId === opts.userId);
|
|
11304
11570
|
if (blocking.length === 0) return [];
|
|
11305
11571
|
if (!opts.autoComplete) {
|
|
11306
11572
|
const nums = blocking.map((c) => c.number).join(", ");
|
|
@@ -11319,7 +11585,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
|
|
|
11319
11585
|
}
|
|
11320
11586
|
return notes;
|
|
11321
11587
|
}
|
|
11322
|
-
async function validateAndPrepare(adapter2, force) {
|
|
11588
|
+
async function validateAndPrepare(adapter2, force, callerUserId) {
|
|
11323
11589
|
let mode;
|
|
11324
11590
|
let cycleNumber;
|
|
11325
11591
|
let strategyReviewWarning = "";
|
|
@@ -11358,7 +11624,7 @@ Run \`review_submit\` to clear them, or pass \`force: true\` to bypass this bloc
|
|
|
11358
11624
|
);
|
|
11359
11625
|
}
|
|
11360
11626
|
}
|
|
11361
|
-
const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true });
|
|
11627
|
+
const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true, userId: callerUserId ?? void 0 });
|
|
11362
11628
|
for (const note of autoCompleted) {
|
|
11363
11629
|
console.error(`[plan] ${note}`);
|
|
11364
11630
|
}
|
|
@@ -11390,7 +11656,8 @@ Run \`strategy_review\` first, or pass \`force: true\` to bypass this gate.`
|
|
|
11390
11656
|
}
|
|
11391
11657
|
async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber, contextHashes, planRunMeta) {
|
|
11392
11658
|
const applyStartMs = Date.now();
|
|
11393
|
-
await
|
|
11659
|
+
const applyScope = await resolvePlanScope(adapter2, config2);
|
|
11660
|
+
await assertSingleActiveCycle(adapter2, { allowNumber: cycleNumber + 1, userId: applyScope.callerUserId ?? void 0 });
|
|
11394
11661
|
const { displayText, data } = parseStructuredOutput(rawOutput);
|
|
11395
11662
|
let resolvedDisplayText = displayText;
|
|
11396
11663
|
let autoCommitNote = "";
|
|
@@ -11409,7 +11676,7 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
|
|
|
11409
11676
|
cycleNumber,
|
|
11410
11677
|
data,
|
|
11411
11678
|
contextHashes,
|
|
11412
|
-
{ confirmCancellations: planRunMeta?.confirmCancellations === true }
|
|
11679
|
+
{ confirmCancellations: planRunMeta?.confirmCancellations === true, ownerUserId: applyScope.callerUserId ?? void 0 }
|
|
11413
11680
|
);
|
|
11414
11681
|
if (wbWarnings.length > 0) {
|
|
11415
11682
|
writeBackWarnings = wbWarnings;
|
|
@@ -11484,7 +11751,8 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
|
|
|
11484
11751
|
const prepareTimer = startTimer();
|
|
11485
11752
|
tracker?.mark("validate_and_prepare");
|
|
11486
11753
|
let t = startTimer();
|
|
11487
|
-
const
|
|
11754
|
+
const prepareScope = await resolvePlanScope(adapter2, config2);
|
|
11755
|
+
const { mode, cycleNumber, strategyReviewWarning } = await validateAndPrepare(adapter2, force, prepareScope.callerUserId);
|
|
11488
11756
|
const validateMs = t();
|
|
11489
11757
|
if (handoffsOnly) {
|
|
11490
11758
|
tracker?.mark("handoffs_only_assemble");
|
|
@@ -11530,8 +11798,8 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
|
|
|
11530
11798
|
t = startTimer();
|
|
11531
11799
|
const { context, contextHashes } = await assembleContext(adapter2, mode, config2, filters, focus);
|
|
11532
11800
|
const assembleMs = t();
|
|
11533
|
-
const
|
|
11534
|
-
if (mode !== "bootstrap" && context.productBrief.includes(
|
|
11801
|
+
const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
|
|
11802
|
+
if (mode !== "bootstrap" && context.productBrief.includes(TEMPLATE_MARKER2)) {
|
|
11535
11803
|
throw new Error("TEMPLATE_BRIEF");
|
|
11536
11804
|
}
|
|
11537
11805
|
if (skipHandoffs) context.skipHandoffs = true;
|
|
@@ -11755,9 +12023,9 @@ function defaultHint(tool) {
|
|
|
11755
12023
|
"release"
|
|
11756
12024
|
]);
|
|
11757
12025
|
if (knownTools.has(tool)) {
|
|
11758
|
-
return `Re-run \`${tool}\`. If the crash repeats,
|
|
12026
|
+
return `Re-run \`${tool}\`. If the crash repeats, offer to submit it upstream \u2014 \`bug\` with report=true \u2014 and ask the user (a) notify when fixed? (b) OK for the PAPI team to reach out?`;
|
|
11759
12027
|
}
|
|
11760
|
-
return "Re-run the tool. If the crash repeats,
|
|
12028
|
+
return "Re-run the tool. If the crash repeats, offer to submit it upstream \u2014 `bug` with report=true \u2014 and ask the user (a) notify when fixed? (b) OK for the PAPI team to reach out?";
|
|
11761
12029
|
}
|
|
11762
12030
|
|
|
11763
12031
|
// src/lib/subagent-dispatch.ts
|
|
@@ -11899,12 +12167,174 @@ async function resolveLlmResponse(inlineResponse, filePath) {
|
|
|
11899
12167
|
return { ok: true, llmResponse: contents };
|
|
11900
12168
|
}
|
|
11901
12169
|
|
|
12170
|
+
// src/services/session-guidance.ts
|
|
12171
|
+
var DEFAULT_CALLER_KEY = "__default__";
|
|
12172
|
+
var sessionStates = /* @__PURE__ */ new Map();
|
|
12173
|
+
var MAX_SESSION_STATES = 1e3;
|
|
12174
|
+
function newSessionState(now) {
|
|
12175
|
+
return {
|
|
12176
|
+
toolCallCount: 0,
|
|
12177
|
+
lastOrientAt: null,
|
|
12178
|
+
releaseSinceLastOrient: false,
|
|
12179
|
+
sessionStartedAt: now,
|
|
12180
|
+
lastReviewListAt: null,
|
|
12181
|
+
failureTimestamps: [],
|
|
12182
|
+
consecutiveFailures: 0,
|
|
12183
|
+
lastTouchedAt: now
|
|
12184
|
+
};
|
|
12185
|
+
}
|
|
12186
|
+
function evictIfNeeded() {
|
|
12187
|
+
if (sessionStates.size <= MAX_SESSION_STATES) return;
|
|
12188
|
+
const entries = [...sessionStates.entries()].sort(
|
|
12189
|
+
(a, b2) => a[1].lastTouchedAt - b2[1].lastTouchedAt
|
|
12190
|
+
);
|
|
12191
|
+
const overflow = sessionStates.size - MAX_SESSION_STATES;
|
|
12192
|
+
for (let i = 0; i < overflow; i++) {
|
|
12193
|
+
sessionStates.delete(entries[i][0]);
|
|
12194
|
+
}
|
|
12195
|
+
}
|
|
12196
|
+
function getState(callerKey) {
|
|
12197
|
+
const key = callerKey ?? DEFAULT_CALLER_KEY;
|
|
12198
|
+
const now = Date.now();
|
|
12199
|
+
let s = sessionStates.get(key);
|
|
12200
|
+
if (!s) {
|
|
12201
|
+
s = newSessionState(now);
|
|
12202
|
+
sessionStates.set(key, s);
|
|
12203
|
+
evictIfNeeded();
|
|
12204
|
+
} else {
|
|
12205
|
+
s.lastTouchedAt = now;
|
|
12206
|
+
}
|
|
12207
|
+
return s;
|
|
12208
|
+
}
|
|
12209
|
+
function callerKeyFromConfig(config2) {
|
|
12210
|
+
return config2.projectId ?? config2.userId ?? void 0;
|
|
12211
|
+
}
|
|
12212
|
+
var CONTEXT_BLOAT_CALL_THRESHOLD = 40;
|
|
12213
|
+
var ORIENT_GAP_MS = 3 * 60 * 60 * 1e3;
|
|
12214
|
+
var REVIEW_LIST_GUARD_WINDOW_MS = 15 * 60 * 1e3;
|
|
12215
|
+
var FAILURE_WINDOW_MS = 30 * 60 * 1e3;
|
|
12216
|
+
var FAILURE_RATE_THRESHOLD = 8;
|
|
12217
|
+
var CONSECUTIVE_FAILURE_THRESHOLD = 4;
|
|
12218
|
+
function recordToolCall(name, callerKey) {
|
|
12219
|
+
const state = getState(callerKey);
|
|
12220
|
+
state.toolCallCount++;
|
|
12221
|
+
if (name === "release") state.releaseSinceLastOrient = true;
|
|
12222
|
+
if (name === "review_list") state.lastReviewListAt = Date.now();
|
|
12223
|
+
}
|
|
12224
|
+
function recordToolOutcome(success, callerKey) {
|
|
12225
|
+
const state = getState(callerKey);
|
|
12226
|
+
if (success) {
|
|
12227
|
+
state.consecutiveFailures = 0;
|
|
12228
|
+
return;
|
|
12229
|
+
}
|
|
12230
|
+
const now = Date.now();
|
|
12231
|
+
state.consecutiveFailures++;
|
|
12232
|
+
state.failureTimestamps.push(now);
|
|
12233
|
+
const cutoff = now - FAILURE_WINDOW_MS;
|
|
12234
|
+
state.failureTimestamps = state.failureTimestamps.filter((t) => t >= cutoff);
|
|
12235
|
+
}
|
|
12236
|
+
function wasReviewListSeenRecently(windowMs = REVIEW_LIST_GUARD_WINDOW_MS, callerKey) {
|
|
12237
|
+
const state = getState(callerKey);
|
|
12238
|
+
if (state.lastReviewListAt == null) return false;
|
|
12239
|
+
return Date.now() - state.lastReviewListAt <= windowMs;
|
|
12240
|
+
}
|
|
12241
|
+
function markOrient(callerKey) {
|
|
12242
|
+
const state = getState(callerKey);
|
|
12243
|
+
state.lastOrientAt = Date.now();
|
|
12244
|
+
state.releaseSinceLastOrient = false;
|
|
12245
|
+
}
|
|
12246
|
+
function getProjectConnectionBanner(projectName, projectSlug) {
|
|
12247
|
+
if (!projectName || !projectSlug) return null;
|
|
12248
|
+
return `[Connected: ${projectName} (${projectSlug})] \u2014 confirm this is the project you mean before I write to it. If it's wrong, don't proceed: pass \`project=<id>\` on the call to target a different project, or fix the project id in your MCP config (PAPI_PROJECT_ID for local, x-papi-project-id header for remote) and reconnect.`;
|
|
12249
|
+
}
|
|
12250
|
+
function detectContextDegradation(now = Date.now(), callerKey) {
|
|
12251
|
+
const state = getState(callerKey);
|
|
12252
|
+
if (state.consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
|
12253
|
+
return `Context degradation (clash): ${state.consecutiveFailures} tool calls failed in a row \u2014 the session may be stuck on a contradiction. Consider a fresh window after this task.`;
|
|
12254
|
+
}
|
|
12255
|
+
const cutoff = now - FAILURE_WINDOW_MS;
|
|
12256
|
+
const recentFailures = state.failureTimestamps.filter((t) => t >= cutoff).length;
|
|
12257
|
+
if (recentFailures >= FAILURE_RATE_THRESHOLD) {
|
|
12258
|
+
const mins = Math.round(FAILURE_WINDOW_MS / 6e4);
|
|
12259
|
+
return `Context degradation (distraction/confusion): ${recentFailures} tool failures in the last ${mins}min. A fresh window often clears this faster than pushing on.`;
|
|
12260
|
+
}
|
|
12261
|
+
return null;
|
|
12262
|
+
}
|
|
12263
|
+
async function buildSessionGuidance(callerKey) {
|
|
12264
|
+
const state = getState(callerKey);
|
|
12265
|
+
const signals = [];
|
|
12266
|
+
const degradation = detectContextDegradation(Date.now(), callerKey);
|
|
12267
|
+
if (degradation) signals.push(degradation);
|
|
12268
|
+
if (state.toolCallCount > CONTEXT_BLOAT_CALL_THRESHOLD) {
|
|
12269
|
+
signals.push(
|
|
12270
|
+
`${state.toolCallCount} tool calls this session \u2014 context may be bloated. Consider starting a fresh window.`
|
|
12271
|
+
);
|
|
12272
|
+
}
|
|
12273
|
+
if (state.lastOrientAt && Date.now() - state.lastOrientAt > ORIENT_GAP_MS) {
|
|
12274
|
+
const hours = Math.round((Date.now() - state.lastOrientAt) / (60 * 60 * 1e3));
|
|
12275
|
+
signals.push(
|
|
12276
|
+
`${hours}h since last orient \u2014 session may be stale. Consider a fresh window for best results.`
|
|
12277
|
+
);
|
|
12278
|
+
}
|
|
12279
|
+
if (state.releaseSinceLastOrient) {
|
|
12280
|
+
signals.push(
|
|
12281
|
+
"Release just ran \u2014 start a fresh session before the next `plan` to keep planning context clean."
|
|
12282
|
+
);
|
|
12283
|
+
}
|
|
12284
|
+
return signals.slice(0, 3);
|
|
12285
|
+
}
|
|
12286
|
+
|
|
12287
|
+
// src/lib/per-caller-cache.ts
|
|
12288
|
+
var DEFAULT_CALLER_KEY2 = "__default__";
|
|
12289
|
+
var MAX_PER_CALLER_ENTRIES = 1e3;
|
|
12290
|
+
var PerCallerCache = class {
|
|
12291
|
+
constructor(max = MAX_PER_CALLER_ENTRIES) {
|
|
12292
|
+
this.max = max;
|
|
12293
|
+
}
|
|
12294
|
+
store = /* @__PURE__ */ new Map();
|
|
12295
|
+
/** Stash the prepare-phase value for this caller (undefined caller → default bucket). */
|
|
12296
|
+
set(callerKey, value) {
|
|
12297
|
+
this.store.set(callerKey ?? DEFAULT_CALLER_KEY2, { value, touchedAt: this.now() });
|
|
12298
|
+
this.evictIfNeeded();
|
|
12299
|
+
}
|
|
12300
|
+
/**
|
|
12301
|
+
* Read the caller's prepare-phase value WITHOUT removing it — used to peek the
|
|
12302
|
+
* expected value during apply-validation (the prior singletons were read then
|
|
12303
|
+
* cleared, so callers that need the clear-on-read semantics use `take`).
|
|
12304
|
+
*/
|
|
12305
|
+
peek(callerKey) {
|
|
12306
|
+
return this.store.get(callerKey ?? DEFAULT_CALLER_KEY2)?.value;
|
|
12307
|
+
}
|
|
12308
|
+
/** Read AND remove this caller's value — the prepare→apply handshake consumes it once. */
|
|
12309
|
+
take(callerKey) {
|
|
12310
|
+
const key = callerKey ?? DEFAULT_CALLER_KEY2;
|
|
12311
|
+
const entry = this.store.get(key);
|
|
12312
|
+
if (entry) this.store.delete(key);
|
|
12313
|
+
return entry?.value;
|
|
12314
|
+
}
|
|
12315
|
+
/** Drop this caller's value without reading it. */
|
|
12316
|
+
clear(callerKey) {
|
|
12317
|
+
this.store.delete(callerKey ?? DEFAULT_CALLER_KEY2);
|
|
12318
|
+
}
|
|
12319
|
+
/** Test/inspection helper — number of live caller buckets. */
|
|
12320
|
+
size() {
|
|
12321
|
+
return this.store.size;
|
|
12322
|
+
}
|
|
12323
|
+
now() {
|
|
12324
|
+
return Date.now();
|
|
12325
|
+
}
|
|
12326
|
+
evictIfNeeded() {
|
|
12327
|
+
if (this.store.size <= this.max) return;
|
|
12328
|
+
const entries = [...this.store.entries()].sort((a, b2) => a[1].touchedAt - b2[1].touchedAt);
|
|
12329
|
+
const overflow = this.store.size - this.max;
|
|
12330
|
+
for (let i = 0; i < overflow; i++) {
|
|
12331
|
+
this.store.delete(entries[i][0]);
|
|
12332
|
+
}
|
|
12333
|
+
}
|
|
12334
|
+
};
|
|
12335
|
+
|
|
11902
12336
|
// src/tools/plan.ts
|
|
11903
|
-
var
|
|
11904
|
-
var lastPrepareUserMessage;
|
|
11905
|
-
var lastPrepareContextBytes;
|
|
11906
|
-
var lastPrepareCycleNumber;
|
|
11907
|
-
var lastPrepareSkipHandoffs;
|
|
12337
|
+
var planPrepareCache = new PerCallerCache();
|
|
11908
12338
|
var planTool = {
|
|
11909
12339
|
name: "plan",
|
|
11910
12340
|
description: 'Run once per cycle to select tasks and generate BUILD HANDOFFs. Call after setup (first time) or after completing all builds AND running release for the previous cycle. Returns prioritised task recommendations with detailed implementation specs. NEVER call when unbuilt cycle tasks exist \u2014 build and release first. First call returns a planning prompt for you to execute (prepare phase). Then call again with mode "apply" and your output to write results. Use skip_handoffs=true for large backlogs \u2014 handoffs are then generated separately via `handoff_generate`.',
|
|
@@ -12046,6 +12476,7 @@ function formatPlanResult(result) {
|
|
|
12046
12476
|
}
|
|
12047
12477
|
async function handlePlan(adapter2, config2, args) {
|
|
12048
12478
|
const toolMode = args.mode;
|
|
12479
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
12049
12480
|
const filters = {};
|
|
12050
12481
|
if (typeof args.phase === "string") filters.phase = args.phase;
|
|
12051
12482
|
if (typeof args.module === "string") filters.module = args.module;
|
|
@@ -12068,11 +12499,12 @@ async function handlePlan(adapter2, config2, args) {
|
|
|
12068
12499
|
const planMode = args.plan_mode || "full";
|
|
12069
12500
|
const rawCycleNumber = args.cycle_number != null ? Number(args.cycle_number) : NaN;
|
|
12070
12501
|
const strategyReviewWarning = args.strategy_review_warning || "";
|
|
12071
|
-
const
|
|
12072
|
-
const
|
|
12073
|
-
const
|
|
12074
|
-
|
|
12075
|
-
|
|
12502
|
+
const prep = planPrepareCache.peek(callerKey);
|
|
12503
|
+
const contextHashes = prep?.contextHashes;
|
|
12504
|
+
const inputContext = prep?.userMessage;
|
|
12505
|
+
const contextBytes = prep?.contextBytes;
|
|
12506
|
+
let expectedCycleNumber = prep?.cycleNumber;
|
|
12507
|
+
const skipHandoffsCached = prep?.skipHandoffs;
|
|
12076
12508
|
const skipHandoffs = args.skip_handoffs === true || skipHandoffsCached === true;
|
|
12077
12509
|
if (expectedCycleNumber === void 0 && process.env.PAPI_GUARD_RESTART === "true") {
|
|
12078
12510
|
try {
|
|
@@ -12094,11 +12526,7 @@ async function handlePlan(adapter2, config2, args) {
|
|
|
12094
12526
|
);
|
|
12095
12527
|
}
|
|
12096
12528
|
const cycleNumber = newCycleNumber - 1;
|
|
12097
|
-
|
|
12098
|
-
lastPrepareUserMessage = void 0;
|
|
12099
|
-
lastPrepareContextBytes = void 0;
|
|
12100
|
-
lastPrepareCycleNumber = void 0;
|
|
12101
|
-
lastPrepareSkipHandoffs = void 0;
|
|
12529
|
+
planPrepareCache.clear(callerKey);
|
|
12102
12530
|
let utilisation;
|
|
12103
12531
|
if (inputContext) {
|
|
12104
12532
|
try {
|
|
@@ -12127,11 +12555,13 @@ async function handlePlan(adapter2, config2, args) {
|
|
|
12127
12555
|
}
|
|
12128
12556
|
const skipHandoffs = args.skip_handoffs === true;
|
|
12129
12557
|
const result = await preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker);
|
|
12130
|
-
|
|
12131
|
-
|
|
12132
|
-
|
|
12133
|
-
|
|
12134
|
-
|
|
12558
|
+
planPrepareCache.set(callerKey, {
|
|
12559
|
+
contextHashes: result.contextHashes,
|
|
12560
|
+
userMessage: result.userMessage,
|
|
12561
|
+
contextBytes: result.contextBytes,
|
|
12562
|
+
cycleNumber: result.cycleNumber,
|
|
12563
|
+
skipHandoffs: skipHandoffs || void 0
|
|
12564
|
+
});
|
|
12135
12565
|
const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
|
|
12136
12566
|
const autoDispatchThreshold = 50 * 1024;
|
|
12137
12567
|
let dispatch;
|
|
@@ -13406,7 +13836,18 @@ ${lines.join("\n")}`;
|
|
|
13406
13836
|
console.error(`[strategy_review] Context size: ${contextSize.toLocaleString()} chars (~${estimatedTokens.toLocaleString()} tokens)`);
|
|
13407
13837
|
return context;
|
|
13408
13838
|
}
|
|
13409
|
-
|
|
13839
|
+
function extractDecisionEvidence(ad, eventType, warnings) {
|
|
13840
|
+
const isDeliberate = eventType === "validated" || eventType === "invalidated" || eventType === "modified";
|
|
13841
|
+
const evidenceRef = ad.evidenceRef?.trim() || void 0;
|
|
13842
|
+
const metricDelta = ad.metricDelta ?? null;
|
|
13843
|
+
if (isDeliberate && !evidenceRef && !metricDelta?.metric) {
|
|
13844
|
+
warnings?.push(
|
|
13845
|
+
`${ad.id}: ${eventType} decision recorded without evidence_ref or metric_delta \u2014 ledger entry has no outcome pointer. Add evidence in the strategy output to make this decision queryable.`
|
|
13846
|
+
);
|
|
13847
|
+
}
|
|
13848
|
+
return { evidenceRef, metricDelta };
|
|
13849
|
+
}
|
|
13850
|
+
async function writeBack2(adapter2, cycleNumber, data, fullAnalysis, warnings) {
|
|
13410
13851
|
const cleanTitle = data.sessionLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
|
|
13411
13852
|
const cleanContent = data.sessionLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
|
|
13412
13853
|
const lastReviewCycle = await adapter2.getLastStrategyReviewCycle();
|
|
@@ -13460,6 +13901,7 @@ ${cleanContent}`;
|
|
|
13460
13901
|
await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
|
|
13461
13902
|
}
|
|
13462
13903
|
const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
|
|
13904
|
+
const evidence = extractDecisionEvidence(ad, eventType, warnings);
|
|
13463
13905
|
try {
|
|
13464
13906
|
await adapter2.appendDecisionEvent({
|
|
13465
13907
|
decisionId: ad.id,
|
|
@@ -13467,7 +13909,9 @@ ${cleanContent}`;
|
|
|
13467
13909
|
cycle: cycleNumber,
|
|
13468
13910
|
source: "strategy_review",
|
|
13469
13911
|
sourceRef: `cycle-${cycleNumber}-review`,
|
|
13470
|
-
detail: `Action: ${ad.action}
|
|
13912
|
+
detail: `Action: ${ad.action}`,
|
|
13913
|
+
evidenceRef: evidence.evidenceRef,
|
|
13914
|
+
metricDelta: evidence.metricDelta
|
|
13471
13915
|
});
|
|
13472
13916
|
} catch {
|
|
13473
13917
|
}
|
|
@@ -13626,6 +14070,7 @@ async function processReviewOutput(adapter2, rawOutput, cycleNumber) {
|
|
|
13626
14070
|
let slackWarning;
|
|
13627
14071
|
let writeBackFailed;
|
|
13628
14072
|
let phaseChanges;
|
|
14073
|
+
const evidenceWarnings = [];
|
|
13629
14074
|
if (!data) {
|
|
13630
14075
|
const marker = "<!-- PAPI_STRUCTURED_OUTPUT -->";
|
|
13631
14076
|
const hasMarker = rawOutput.includes(marker);
|
|
@@ -13639,7 +14084,7 @@ async function processReviewOutput(adapter2, rawOutput, cycleNumber) {
|
|
|
13639
14084
|
}
|
|
13640
14085
|
if (data) {
|
|
13641
14086
|
try {
|
|
13642
|
-
phaseChanges = await writeBack2(adapter2, cycleNumber, data, displayText);
|
|
14087
|
+
phaseChanges = await writeBack2(adapter2, cycleNumber, data, displayText, evidenceWarnings);
|
|
13643
14088
|
} catch (err) {
|
|
13644
14089
|
writeBackFailed = err instanceof Error ? err.message : String(err);
|
|
13645
14090
|
try {
|
|
@@ -13687,9 +14132,15 @@ ${report}`;
|
|
|
13687
14132
|
**Discovery Canvas Auto-Populated:** ${populated.join(", ")}`;
|
|
13688
14133
|
}
|
|
13689
14134
|
}
|
|
14135
|
+
const evidenceWarningSection = evidenceWarnings.length ? `
|
|
14136
|
+
|
|
14137
|
+
---
|
|
14138
|
+
|
|
14139
|
+
**Decision-ledger evidence warnings (${evidenceWarnings.length}):**
|
|
14140
|
+
${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : "";
|
|
13690
14141
|
const fullText = phaseChanges?.length ? `${displayText}
|
|
13691
14142
|
|
|
13692
|
-
${formatPhaseChanges(phaseChanges)}${valueReportSection}${canvasSection}` : `${displayText}${valueReportSection}${canvasSection}`;
|
|
14143
|
+
${formatPhaseChanges(phaseChanges)}${valueReportSection}${canvasSection}${evidenceWarningSection}` : `${displayText}${valueReportSection}${canvasSection}${evidenceWarningSection}`;
|
|
13693
14144
|
return {
|
|
13694
14145
|
cycleNumber,
|
|
13695
14146
|
displayText: fullText,
|
|
@@ -13996,6 +14447,7 @@ function buildStrategyChangeUserMessage(cycleNumber, text, productBrief, activeD
|
|
|
13996
14447
|
async function processStrategyChangeOutput(adapter2, rawOutput, cycleNumber) {
|
|
13997
14448
|
const { displayText, data } = parseStrategyChangeOutput(rawOutput);
|
|
13998
14449
|
let writeBackFailed;
|
|
14450
|
+
const evidenceWarnings = [];
|
|
13999
14451
|
if (data) {
|
|
14000
14452
|
try {
|
|
14001
14453
|
const cleanTitle = data.cycleLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
|
|
@@ -14023,6 +14475,7 @@ ${cleanContent}`;
|
|
|
14023
14475
|
await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
|
|
14024
14476
|
}
|
|
14025
14477
|
const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
|
|
14478
|
+
const evidence = extractDecisionEvidence(ad, eventType, evidenceWarnings);
|
|
14026
14479
|
try {
|
|
14027
14480
|
await adapter2.appendDecisionEvent({
|
|
14028
14481
|
decisionId: ad.id,
|
|
@@ -14030,7 +14483,9 @@ ${cleanContent}`;
|
|
|
14030
14483
|
cycle: cycleNumber,
|
|
14031
14484
|
source: "strategy_change",
|
|
14032
14485
|
sourceRef: `cycle-${cycleNumber}-change`,
|
|
14033
|
-
detail: `Action: ${ad.action}
|
|
14486
|
+
detail: `Action: ${ad.action}`,
|
|
14487
|
+
evidenceRef: evidence.evidenceRef,
|
|
14488
|
+
metricDelta: evidence.metricDelta
|
|
14034
14489
|
});
|
|
14035
14490
|
} catch {
|
|
14036
14491
|
}
|
|
@@ -14050,7 +14505,13 @@ ${cleanContent}`;
|
|
|
14050
14505
|
writeBackFailed = err instanceof Error ? err.message : String(err);
|
|
14051
14506
|
}
|
|
14052
14507
|
}
|
|
14053
|
-
|
|
14508
|
+
const fullText = evidenceWarnings.length ? `${displayText}
|
|
14509
|
+
|
|
14510
|
+
---
|
|
14511
|
+
|
|
14512
|
+
**Decision-ledger evidence warnings (${evidenceWarnings.length}):**
|
|
14513
|
+
${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : displayText;
|
|
14514
|
+
return { cycleNumber, displayText: fullText, writeBackFailed };
|
|
14054
14515
|
}
|
|
14055
14516
|
async function prepareStrategyChange(adapter2, text) {
|
|
14056
14517
|
let cycleNumber;
|
|
@@ -14189,11 +14650,10 @@ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change c
|
|
|
14189
14650
|
}
|
|
14190
14651
|
|
|
14191
14652
|
// src/tools/strategy.ts
|
|
14192
|
-
var
|
|
14193
|
-
var lastReviewContextBytes;
|
|
14653
|
+
var reviewPrepareCache = new PerCallerCache();
|
|
14194
14654
|
var strategyReviewTool = {
|
|
14195
14655
|
name: "strategy_review",
|
|
14196
|
-
description: 'Run a Strategy Review \u2014 assesses project direction, velocity, and Active Decisions. Produces recommendations and potential AD updates that feed into the next plan. Offered every 5 cycles; hard-blocked at 7+ overdue cycles.
|
|
14656
|
+
description: 'Run a Strategy Review \u2014 assesses project direction, velocity, and Active Decisions. Produces recommendations and potential AD updates that feed into the next plan. Offered every 5 cycles; hard-blocked at 7+ overdue cycles. If your current session is already heavy with build context, running the review in a fresh session gives cleaner output \u2014 but a genuinely fresh session needs no restart, just run it. First call returns a review prompt for you to execute (prepare phase). Then call again with mode "apply" and your output. Pass `force: true` to run before the cadence gate.',
|
|
14197
14657
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
14198
14658
|
inputSchema: {
|
|
14199
14659
|
type: "object",
|
|
@@ -14258,7 +14718,7 @@ var strategyAgendaTool = {
|
|
|
14258
14718
|
};
|
|
14259
14719
|
var strategyChangeTool = {
|
|
14260
14720
|
name: "strategy_change",
|
|
14261
|
-
description: 'Apply a strategic shift to the project. Three modes: "capture" for lightweight mid-conversation decision capture (no LLM round-trip), "prepare" to get a change prompt for full analysis, "apply" to persist analysis output. Use "capture" when you detect a strategic decision in conversation and want to persist it quickly without disrupting the build flow.',
|
|
14721
|
+
description: 'Apply a strategic shift to the project. Three modes: "capture" for lightweight mid-conversation decision capture (no LLM round-trip), "prepare" to get a change prompt for full analysis, "apply" to persist analysis output. Use "capture" when you detect a strategic decision in conversation and want to persist it quickly without disrupting the build flow. In "capture" mode, pass north_star to directly set/update the project North Star (no decision text needed).',
|
|
14262
14722
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
14263
14723
|
inputSchema: {
|
|
14264
14724
|
type: "object",
|
|
@@ -14296,6 +14756,10 @@ var strategyChangeTool = {
|
|
|
14296
14756
|
confidence_only: {
|
|
14297
14757
|
type: "boolean",
|
|
14298
14758
|
description: `When true (mode "capture" + ad_id required), only update the confidence level \u2014 leave the AD body unchanged. Use when evidence strength changes but the decision itself hasn't shifted.`
|
|
14759
|
+
},
|
|
14760
|
+
north_star: {
|
|
14761
|
+
type: "string",
|
|
14762
|
+
description: 'mode "capture" only \u2014 set/update the project North Star statement directly. orient and the project foundation read it. No decision text required when this is provided.'
|
|
14299
14763
|
}
|
|
14300
14764
|
},
|
|
14301
14765
|
required: []
|
|
@@ -14304,6 +14768,7 @@ var strategyChangeTool = {
|
|
|
14304
14768
|
async function handleStrategyReview(adapter2, config2, args) {
|
|
14305
14769
|
const toolMode = args.mode;
|
|
14306
14770
|
const force = args.force === true;
|
|
14771
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
14307
14772
|
const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate");
|
|
14308
14773
|
try {
|
|
14309
14774
|
if (toolMode === "apply") {
|
|
@@ -14316,10 +14781,9 @@ async function handleStrategyReview(adapter2, config2, args) {
|
|
|
14316
14781
|
}
|
|
14317
14782
|
const llmResponse = resolved.llmResponse;
|
|
14318
14783
|
const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : 0;
|
|
14319
|
-
const
|
|
14320
|
-
const
|
|
14321
|
-
|
|
14322
|
-
lastReviewContextBytes = void 0;
|
|
14784
|
+
const prep = reviewPrepareCache.take(callerKey);
|
|
14785
|
+
const inputContext = prep?.userMessage;
|
|
14786
|
+
const contextBytes = prep?.contextBytes;
|
|
14323
14787
|
const result = await applyStrategyReviewOutput(adapter2, llmResponse, cycleNumber, tracker);
|
|
14324
14788
|
let utilisation;
|
|
14325
14789
|
if (inputContext) {
|
|
@@ -14384,14 +14848,17 @@ ${recLines.join("\n")}
|
|
|
14384
14848
|
if ("notDue" in result) {
|
|
14385
14849
|
return textResponse(result.message);
|
|
14386
14850
|
}
|
|
14387
|
-
|
|
14388
|
-
|
|
14851
|
+
const reviewContextBytes = Buffer.byteLength(result.userMessage, "utf-8");
|
|
14852
|
+
reviewPrepareCache.set(callerKey, {
|
|
14853
|
+
userMessage: result.userMessage,
|
|
14854
|
+
contextBytes: reviewContextBytes
|
|
14855
|
+
});
|
|
14389
14856
|
const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
|
|
14390
14857
|
const autoDispatchThreshold = 50 * 1024;
|
|
14391
14858
|
let dispatch;
|
|
14392
14859
|
if (args.dispatch === "inline" || args.dispatch === "subagent") {
|
|
14393
14860
|
dispatch = args.dispatch;
|
|
14394
|
-
} else if (autoDispatchEnabled &&
|
|
14861
|
+
} else if (autoDispatchEnabled && reviewContextBytes > autoDispatchThreshold) {
|
|
14395
14862
|
dispatch = "subagent";
|
|
14396
14863
|
} else {
|
|
14397
14864
|
dispatch = "inline";
|
|
@@ -14402,7 +14869,7 @@ ${recLines.join("\n")}
|
|
|
14402
14869
|
cycleNumber: result.cycleNumber,
|
|
14403
14870
|
systemPrompt: result.systemPrompt,
|
|
14404
14871
|
userMessage: result.userMessage,
|
|
14405
|
-
contextBytes:
|
|
14872
|
+
contextBytes: reviewContextBytes
|
|
14406
14873
|
});
|
|
14407
14874
|
return textResponse(dispatchPrompt);
|
|
14408
14875
|
}
|
|
@@ -14499,9 +14966,25 @@ async function handleStrategyChange(adapter2, _config, args) {
|
|
|
14499
14966
|
const toolMode = args.mode;
|
|
14500
14967
|
try {
|
|
14501
14968
|
if (toolMode === "capture") {
|
|
14969
|
+
const northStar = args.north_star;
|
|
14970
|
+
if (northStar && northStar.trim()) {
|
|
14971
|
+
if (!adapter2.upsertNorthStar) {
|
|
14972
|
+
return errorResponse("North Star write is not available on this adapter (requires the pg/proxy or md adapter).");
|
|
14973
|
+
}
|
|
14974
|
+
const health = await adapter2.getCycleHealth();
|
|
14975
|
+
const cycleNumber = health.totalCycles;
|
|
14976
|
+
await adapter2.upsertNorthStar(northStar.trim(), cycleNumber);
|
|
14977
|
+
return textResponse(
|
|
14978
|
+
`**North Star set** (Cycle ${cycleNumber})
|
|
14979
|
+
|
|
14980
|
+
${northStar.trim()}
|
|
14981
|
+
|
|
14982
|
+
orient and the project foundation will read this value.`
|
|
14983
|
+
);
|
|
14984
|
+
}
|
|
14502
14985
|
const text2 = args.text;
|
|
14503
14986
|
if (!text2 || !text2.trim()) {
|
|
14504
|
-
return errorResponse("text is required for capture mode. Describe the decision.");
|
|
14987
|
+
return errorResponse("text is required for capture mode. Describe the decision (or pass north_star to set the project North Star).");
|
|
14505
14988
|
}
|
|
14506
14989
|
const adId = args.ad_id;
|
|
14507
14990
|
const confidence = args.confidence ?? "MEDIUM";
|
|
@@ -14612,7 +15095,17 @@ async function viewBoard(adapter2, phaseFilter, options) {
|
|
|
14612
15095
|
const allTasks = await adapter2.queryBoard(
|
|
14613
15096
|
Object.keys(queryOptions).length > 0 ? queryOptions : void 0
|
|
14614
15097
|
);
|
|
14615
|
-
|
|
15098
|
+
let filtered = allTasks;
|
|
15099
|
+
if (options?.cycle != null) {
|
|
15100
|
+
filtered = filtered.filter((t) => t.cycle === options.cycle);
|
|
15101
|
+
}
|
|
15102
|
+
if (options?.query && options.query.trim()) {
|
|
15103
|
+
const q = options.query.trim().toLowerCase();
|
|
15104
|
+
filtered = filtered.filter(
|
|
15105
|
+
(t) => t.title.toLowerCase().includes(q) || (t.notes?.toLowerCase().includes(q) ?? false)
|
|
15106
|
+
);
|
|
15107
|
+
}
|
|
15108
|
+
filtered.sort((a, b2) => {
|
|
14616
15109
|
const aTier = STATUS_TIER[a.status] ?? 4;
|
|
14617
15110
|
const bTier = STATUS_TIER[b2.status] ?? 4;
|
|
14618
15111
|
if (aTier !== bTier) return aTier - bTier;
|
|
@@ -14624,10 +15117,10 @@ async function viewBoard(adapter2, phaseFilter, options) {
|
|
|
14624
15117
|
const bDate = b2.createdAt ? String(b2.createdAt) : "";
|
|
14625
15118
|
return bDate.localeCompare(aDate);
|
|
14626
15119
|
});
|
|
14627
|
-
const total =
|
|
15120
|
+
const total = filtered.length;
|
|
14628
15121
|
const offset = options?.offset ?? 0;
|
|
14629
15122
|
const limit = options?.limit ?? 50;
|
|
14630
|
-
const paged =
|
|
15123
|
+
const paged = filtered.slice(offset, offset + limit);
|
|
14631
15124
|
return {
|
|
14632
15125
|
tasks: paged,
|
|
14633
15126
|
total,
|
|
@@ -14684,11 +15177,23 @@ async function archiveTasks(adapter2, phases, statuses) {
|
|
|
14684
15177
|
// src/tools/board.ts
|
|
14685
15178
|
var boardViewTool = {
|
|
14686
15179
|
name: "board_view",
|
|
14687
|
-
description: 'View the Board. By default shows active tasks only (excludes Done/Cancelled), sorted by priority, limited to 50. Use status="all" to see everything
|
|
15180
|
+
description: 'View the Board. To find a SPECIFIC task or subset, FILTER FIRST \u2014 do not dump the whole board: pass task_id for one task (full detail), query="<text>" for a title/notes substring match, or cycle=<n> for one cycle. Combine with status/phase. By default shows active tasks only (excludes Done/Cancelled), sorted by priority, limited to 50; titles are truncated in the table (single-task lookup shows the full title). Use status="all" to see everything, mode="summary" for counts only. Does not call the Anthropic API.',
|
|
14688
15181
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
14689
15182
|
inputSchema: {
|
|
14690
15183
|
type: "object",
|
|
14691
15184
|
properties: {
|
|
15185
|
+
task_id: {
|
|
15186
|
+
type: "string",
|
|
15187
|
+
description: 'Direct lookup of a single task by its display_id (e.g. "task-2154"). Returns full untruncated detail for just that task \u2014 no board scan. Takes precedence over the list filters.'
|
|
15188
|
+
},
|
|
15189
|
+
query: {
|
|
15190
|
+
type: "string",
|
|
15191
|
+
description: "Case-insensitive substring match on task title OR notes. Returns only matching tasks \u2014 use this instead of dumping the whole board to find something."
|
|
15192
|
+
},
|
|
15193
|
+
cycle: {
|
|
15194
|
+
type: "number",
|
|
15195
|
+
description: "Filter to tasks assigned to this cycle number."
|
|
15196
|
+
},
|
|
14692
15197
|
phase: {
|
|
14693
15198
|
type: "string",
|
|
14694
15199
|
description: 'Filter to tasks in this phase (e.g. "Phase 5").'
|
|
@@ -14828,14 +15333,45 @@ var boardEditTool = {
|
|
|
14828
15333
|
cycle: {
|
|
14829
15334
|
type: ["number", "null"],
|
|
14830
15335
|
description: "Cycle assignment. Pass a cycle number to assign, or null to remove from any cycle. Validated against existing cycles. Replaces the prior workaround of editing cycle_tasks.cycle directly via SQL."
|
|
14831
|
-
}
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
15336
|
+
},
|
|
15337
|
+
estimated_effort: {
|
|
15338
|
+
type: "string",
|
|
15339
|
+
enum: ["XS", "S", "M", "L", "XL"],
|
|
15340
|
+
description: "task-2182: correct the estimated effort on this task's LATEST build report (fixes a mis-recorded estimate). Updates the build_reports row, not the task \u2014 feeds the next estimation-accuracy recompute."
|
|
15341
|
+
},
|
|
15342
|
+
actual_effort: {
|
|
15343
|
+
type: "string",
|
|
15344
|
+
enum: ["XS", "S", "M", "L", "XL"],
|
|
15345
|
+
description: "task-2182: correct the actual effort on this task's LATEST build report (fixes a mis-recorded actual)."
|
|
15346
|
+
}
|
|
15347
|
+
},
|
|
15348
|
+
required: ["task_id"]
|
|
15349
|
+
}
|
|
15350
|
+
};
|
|
14836
15351
|
function pad(value, width) {
|
|
14837
15352
|
return value.length >= width ? value : value + " ".repeat(width - value.length);
|
|
14838
15353
|
}
|
|
15354
|
+
var TITLE_MAX = 80;
|
|
15355
|
+
function truncateTitle(title) {
|
|
15356
|
+
return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}\u2026` : title;
|
|
15357
|
+
}
|
|
15358
|
+
function formatSingleTask(t) {
|
|
15359
|
+
const lines = [
|
|
15360
|
+
`**${t.id} \u2014 ${t.title}**`,
|
|
15361
|
+
"",
|
|
15362
|
+
`- **Status:** ${t.status}`,
|
|
15363
|
+
`- **Priority:** ${t.priority}`,
|
|
15364
|
+
`- **Cycle:** ${t.cycle != null ? t.cycle : "-"}`,
|
|
15365
|
+
`- **Phase:** ${t.phase ?? "-"}`,
|
|
15366
|
+
`- **Module:** ${t.module ?? "-"}`,
|
|
15367
|
+
`- **Epic:** ${t.epic ?? "-"}`,
|
|
15368
|
+
`- **Effort:** ${t.complexity ?? "-"}`,
|
|
15369
|
+
`- **Source:** ${t.source ?? "-"}`
|
|
15370
|
+
];
|
|
15371
|
+
if (t.notes?.trim()) lines.push(`- **Notes:** ${t.notes.trim()}`);
|
|
15372
|
+
if (t.why?.trim()) lines.push(`- **Why:** ${t.why.trim()}`);
|
|
15373
|
+
return lines.join("\n");
|
|
15374
|
+
}
|
|
14839
15375
|
function formatBoard(result) {
|
|
14840
15376
|
if (result.tasks.length === 0) {
|
|
14841
15377
|
return "No tasks found.";
|
|
@@ -14844,7 +15380,7 @@ function formatBoard(result) {
|
|
|
14844
15380
|
const rows = result.tasks.map((t) => [
|
|
14845
15381
|
t.priority,
|
|
14846
15382
|
t.id,
|
|
14847
|
-
t.title,
|
|
15383
|
+
truncateTitle(t.title),
|
|
14848
15384
|
t.status,
|
|
14849
15385
|
t.cycle != null ? String(t.cycle) : "-",
|
|
14850
15386
|
t.phase ?? "-",
|
|
@@ -14896,11 +15432,19 @@ async function handleBoardView(adapter2, args) {
|
|
|
14896
15432
|
const summary = await viewBoardSummary(adapter2);
|
|
14897
15433
|
return textResponse(formatSummary(summary));
|
|
14898
15434
|
}
|
|
15435
|
+
const taskIdArg = args.task_id ?? args.display_id;
|
|
15436
|
+
if (taskIdArg) {
|
|
15437
|
+
const task = await adapter2.getTask(taskIdArg);
|
|
15438
|
+
if (!task) return errorResponse(`Task ${taskIdArg} not found.`);
|
|
15439
|
+
return textResponse(formatSingleTask(task));
|
|
15440
|
+
}
|
|
14899
15441
|
const result = await viewBoard(adapter2, void 0, {
|
|
14900
15442
|
phase: args.phase,
|
|
14901
15443
|
status: args.status,
|
|
14902
15444
|
limit: args.limit,
|
|
14903
|
-
offset: args.offset
|
|
15445
|
+
offset: args.offset,
|
|
15446
|
+
query: args.query,
|
|
15447
|
+
cycle: args.cycle
|
|
14904
15448
|
});
|
|
14905
15449
|
let output = formatBoard(result);
|
|
14906
15450
|
try {
|
|
@@ -15022,6 +15566,15 @@ async function handleBoardEdit(adapter2, args) {
|
|
|
15022
15566
|
changes.push(field);
|
|
15023
15567
|
}
|
|
15024
15568
|
}
|
|
15569
|
+
const effortCorrection = {};
|
|
15570
|
+
if (typeof args.estimated_effort === "string") {
|
|
15571
|
+
effortCorrection.estimatedEffort = args.estimated_effort;
|
|
15572
|
+
changes.push("estimated_effort");
|
|
15573
|
+
}
|
|
15574
|
+
if (typeof args.actual_effort === "string") {
|
|
15575
|
+
effortCorrection.actualEffort = args.actual_effort;
|
|
15576
|
+
changes.push("actual_effort");
|
|
15577
|
+
}
|
|
15025
15578
|
const notesMode = args.notes_mode;
|
|
15026
15579
|
if (notesMode === "clear" && !changes.includes("notes")) {
|
|
15027
15580
|
changes.push("notes");
|
|
@@ -15106,7 +15659,15 @@ ${existing}` : entry;
|
|
|
15106
15659
|
if (updates.status === "Cancelled" && !updates.cancelledBy) {
|
|
15107
15660
|
updates.cancelledBy = "user";
|
|
15108
15661
|
}
|
|
15109
|
-
|
|
15662
|
+
if (effortCorrection.estimatedEffort || effortCorrection.actualEffort) {
|
|
15663
|
+
if (!adapter2.correctLatestBuildReportEffort) {
|
|
15664
|
+
return errorResponse("Correcting build-report effort requires a database adapter (pg). The md adapter does not support it.");
|
|
15665
|
+
}
|
|
15666
|
+
await adapter2.correctLatestBuildReportEffort(taskId, effortCorrection);
|
|
15667
|
+
}
|
|
15668
|
+
if (Object.keys(updates).length > 0) {
|
|
15669
|
+
await adapter2.updateTask(taskId, updates);
|
|
15670
|
+
}
|
|
15110
15671
|
if ((updates.status === "Done" || updates.status === "Cancelled") && adapter2.updateDogfoodEntryStatus) {
|
|
15111
15672
|
try {
|
|
15112
15673
|
const dogfoodLog = await adapter2.getDogfoodLog?.(50) ?? [];
|
|
@@ -15383,7 +15944,7 @@ This is a one-time check at the start of the cycle, not per-task. It catches sco
|
|
|
15383
15944
|
Every 5 cycles, PAPI offers a strategy review \u2014 a deep analysis of velocity, estimation accuracy, active decisions, and project direction.
|
|
15384
15945
|
|
|
15385
15946
|
- **Don't skip them.** They're where compounding value comes from.
|
|
15386
|
-
-
|
|
15947
|
+
- If your session is already heavy with build context, run the review fresh for cleaner output \u2014 a genuinely fresh session needs no restart.
|
|
15387
15948
|
- Reviews produce recommendations that feed into the next plan.
|
|
15388
15949
|
- If the review recommends AD changes, use \`strategy_change\` to apply them.
|
|
15389
15950
|
|
|
@@ -15781,6 +16342,9 @@ async function ensurePapiPermission(projectRoot) {
|
|
|
15781
16342
|
} catch {
|
|
15782
16343
|
}
|
|
15783
16344
|
}
|
|
16345
|
+
var TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
|
|
16346
|
+
var CONVENTIONS_SENTINEL = "<!-- PAPI_CONVENTIONS -->";
|
|
16347
|
+
var CONVENTIONS_HEADING = "## Code Style Conventions";
|
|
15784
16348
|
async function applySetupOutputs(adapter2, config2, input, collector, briefText, adSeedText, conventionsText) {
|
|
15785
16349
|
const warnings = [];
|
|
15786
16350
|
await adapter2.updateProductBrief(briefText);
|
|
@@ -15818,13 +16382,21 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
|
|
|
15818
16382
|
}
|
|
15819
16383
|
}
|
|
15820
16384
|
let seededAds = 0;
|
|
16385
|
+
let skippedAds = 0;
|
|
15821
16386
|
if (adSeedText) {
|
|
15822
16387
|
try {
|
|
15823
16388
|
const cleaned = adSeedText.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
|
|
15824
16389
|
const ads = JSON.parse(cleaned);
|
|
15825
16390
|
if (Array.isArray(ads)) {
|
|
16391
|
+
const existingAdIds = adapter2.getActiveDecisions ? new Set(
|
|
16392
|
+
(await adapter2.getActiveDecisions({ includeRetired: true }).catch(() => [])).map((a) => a.displayId)
|
|
16393
|
+
) : /* @__PURE__ */ new Set();
|
|
15826
16394
|
for (const ad of ads) {
|
|
15827
16395
|
if (ad.id && ad.body) {
|
|
16396
|
+
if (existingAdIds.has(ad.id) && !input.force) {
|
|
16397
|
+
skippedAds++;
|
|
16398
|
+
continue;
|
|
16399
|
+
}
|
|
15828
16400
|
if (adapter2.upsertActiveDecision) {
|
|
15829
16401
|
const title = ad.title || ad.body.split("\n")[0].replace(/^#+\s*/, "").slice(0, 120);
|
|
15830
16402
|
await adapter2.upsertActiveDecision(ad.id, ad.body, title, ad.confidence || "MEDIUM", 0);
|
|
@@ -15842,7 +16414,11 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
|
|
|
15842
16414
|
);
|
|
15843
16415
|
seededAds = 0;
|
|
15844
16416
|
}
|
|
15845
|
-
if (
|
|
16417
|
+
if (skippedAds > 0) {
|
|
16418
|
+
warnings.push(
|
|
16419
|
+
`Active Decisions: detected ${skippedAds} existing AD(s) and left them untouched${seededAds > 0 ? `; created ${seededAds} new one(s)` : " (none new to create)"}.`
|
|
16420
|
+
);
|
|
16421
|
+
} else if (seededAds === 0 && adSeedText) {
|
|
15846
16422
|
if (!warnings.some((w) => w.startsWith("AD seeding failed"))) {
|
|
15847
16423
|
warnings.push(
|
|
15848
16424
|
"AD seeding produced 0 active decisions \u2014 the JSON may be valid but empty or missing required `id` and `body` fields."
|
|
@@ -15851,17 +16427,29 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
|
|
|
15851
16427
|
}
|
|
15852
16428
|
}
|
|
15853
16429
|
if (conventionsText?.trim()) {
|
|
16430
|
+
const conventionsBlock = `${CONVENTIONS_SENTINEL}
|
|
16431
|
+
${conventionsText.trim()}
|
|
16432
|
+
`;
|
|
15854
16433
|
if (config2.adapterType === "proxy") {
|
|
15855
16434
|
collector.add({
|
|
15856
16435
|
path: "CLAUDE.md",
|
|
15857
|
-
content: "\n" +
|
|
16436
|
+
content: "\n" + conventionsBlock,
|
|
15858
16437
|
mode: "append"
|
|
15859
16438
|
});
|
|
16439
|
+
warnings.push(
|
|
16440
|
+
`Conventions: append the block to CLAUDE.md only if it does not already contain \`${CONVENTIONS_SENTINEL}\` or a "${CONVENTIONS_HEADING}" section.`
|
|
16441
|
+
);
|
|
15860
16442
|
} else {
|
|
15861
16443
|
try {
|
|
15862
16444
|
const claudeMdPath = join5(config2.projectRoot, "CLAUDE.md");
|
|
15863
16445
|
const existing = await readFile4(claudeMdPath, "utf-8");
|
|
15864
|
-
|
|
16446
|
+
if (existing.includes(CONVENTIONS_SENTINEL) || existing.includes(CONVENTIONS_HEADING)) {
|
|
16447
|
+
warnings.push(
|
|
16448
|
+
"Conventions: CLAUDE.md already contains a conventions block \u2014 skipped append to avoid duplication."
|
|
16449
|
+
);
|
|
16450
|
+
} else {
|
|
16451
|
+
await writeFile2(claudeMdPath, existing + "\n" + conventionsBlock, "utf-8");
|
|
16452
|
+
}
|
|
15865
16453
|
} catch {
|
|
15866
16454
|
}
|
|
15867
16455
|
}
|
|
@@ -16048,8 +16636,8 @@ async function prepareSetup(adapter2, config2, input) {
|
|
|
16048
16636
|
config2.adapterType === "proxy" ? "Could not read Product Brief from PAPI. Check your PAPI connection (OAuth sign-in, bearer token, or PAPI_DATA_API_KEY) and that PAPI_PROJECT_ID matches the project you want to scaffold." : config2.adapterType === "pg" ? "Could not read Product Brief from database. Check DATABASE_URL and PAPI_PROJECT_ID are set correctly in your MCP config." : "Could not read PRODUCT_BRIEF.md. Ensure .papi/ directory exists."
|
|
16049
16637
|
);
|
|
16050
16638
|
}
|
|
16051
|
-
const
|
|
16052
|
-
const briefHasRealContent = existingBrief.trim().length > 0 && !existingBrief.includes(
|
|
16639
|
+
const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
|
|
16640
|
+
const briefHasRealContent = existingBrief.trim().length > 0 && !existingBrief.includes(TEMPLATE_MARKER2);
|
|
16053
16641
|
const briefAlreadyExists = briefHasRealContent && !input.force;
|
|
16054
16642
|
const canScanFilesystem = hasLocalWorkspace();
|
|
16055
16643
|
const warnings = [];
|
|
@@ -16160,6 +16748,15 @@ async function prepareSetup(adapter2, config2, input) {
|
|
|
16160
16748
|
targetUsers: input.targetUsers,
|
|
16161
16749
|
codebaseContext: codebaseSummary
|
|
16162
16750
|
})
|
|
16751
|
+
} : input.description?.trim() ? {
|
|
16752
|
+
system: VISION_TASKS_SYSTEM,
|
|
16753
|
+
user: buildVisionTasksPrompt({
|
|
16754
|
+
projectName: input.projectName,
|
|
16755
|
+
description: input.description,
|
|
16756
|
+
targetUsers: input.targetUsers,
|
|
16757
|
+
problems: input.problems,
|
|
16758
|
+
projectType: input.projectType
|
|
16759
|
+
})
|
|
16163
16760
|
} : void 0;
|
|
16164
16761
|
return {
|
|
16165
16762
|
createdProject,
|
|
@@ -16181,7 +16778,6 @@ async function prepareSetup(adapter2, config2, input) {
|
|
|
16181
16778
|
async function applySetup(adapter2, config2, input, briefText, adSeedText, conventionsText, initialTasksText) {
|
|
16182
16779
|
const collector = new FileWriteCollector();
|
|
16183
16780
|
const createdProject = await scaffoldPapiDir(adapter2, config2, input, collector);
|
|
16184
|
-
const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
|
|
16185
16781
|
let effectiveBriefText = briefText;
|
|
16186
16782
|
let briefRegenerated = false;
|
|
16187
16783
|
if (!effectiveBriefText.trim()) {
|
|
@@ -16240,7 +16836,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
|
|
|
16240
16836
|
`100% overlap \u2014 all ${validTasks.length} initial task(s) already exist in the board (matched by title). Re-run without initial_tasks_response, or add force: true to skip the overlap guard.`
|
|
16241
16837
|
);
|
|
16242
16838
|
}
|
|
16243
|
-
const MAX_INITIAL_TASKS =
|
|
16839
|
+
const MAX_INITIAL_TASKS = 20;
|
|
16244
16840
|
const failedTasks = [];
|
|
16245
16841
|
for (const task of toCreate.slice(0, MAX_INITIAL_TASKS)) {
|
|
16246
16842
|
try {
|
|
@@ -16516,10 +17112,11 @@ ${result.seededAds} Active Decision${result.seededAds > 1 ? "s" : ""} seeded bas
|
|
|
16516
17112
|
const taskNote = result.createdTasks > 0 || (result.tasksSkipped ?? 0) > 0 ? (() => {
|
|
16517
17113
|
const created = result.createdTasks > 0 ? `${result.createdTasks} initial backlog task${result.createdTasks > 1 ? "s" : ""} created` : "";
|
|
16518
17114
|
const skipped = (result.tasksSkipped ?? 0) > 0 ? `${result.tasksSkipped} duplicate${(result.tasksSkipped ?? 0) > 1 ? "s" : ""} skipped` : "";
|
|
17115
|
+
const idea = result.createdTasks < 8 ? ` Your backlog is light \u2014 add more with \`idea "<what you want to build>"\` so your next plan has fuel.` : ` Capture new ideas any time with \`idea "<...>"\` \u2014 mid-build or after a cycle, it all feeds the next plan.`;
|
|
16519
17116
|
return `
|
|
16520
17117
|
|
|
16521
|
-
${[created, skipped].filter(Boolean).join(", ")}
|
|
16522
|
-
})() : "";
|
|
17118
|
+
${[created, skipped].filter(Boolean).join(", ")}.${idea}`;
|
|
17119
|
+
})() : '\n\nNo starter tasks yet. Describe what you want to build, or seed the board with `idea "<what you want to build>"`, so your first `plan` has something to work from.';
|
|
16523
17120
|
const constraintsHint = !constraints ? '\n\nTip: consider adding `constraints` (e.g. "must use PostgreSQL", "HIPAA compliant", "offline-first") to improve Active Decision seeding.' : "";
|
|
16524
17121
|
const editorNote = result.cursorScaffolded ? "\n\nCursor detected \u2014 `.cursor/rules/papi.mdc` scaffolded alongside CLAUDE.md." : "";
|
|
16525
17122
|
const gitignoreNote = result.gitignoreNote ? `
|
|
@@ -16619,6 +17216,17 @@ PAPI needs the project name. Description and target users are optional \u2014 th
|
|
|
16619
17216
|
""
|
|
16620
17217
|
);
|
|
16621
17218
|
}
|
|
17219
|
+
if (result.briefPrompt && !result.briefAlreadyExists) {
|
|
17220
|
+
sections.push(
|
|
17221
|
+
`**\u{1F4C4} Before you write the brief \u2014 does the user already have a PRD, brief, spec, or design doc?**`,
|
|
17222
|
+
`A brief generated from the user's real spec produces a far better first plan than a generic scaffold.`,
|
|
17223
|
+
`1. **Ask:** "Do you already have a PRD, brief, spec, or design doc for this \u2014 in this folder or anywhere else (even another directory)? Point me at it or paste it in."`,
|
|
17224
|
+
`2. **Scan the working directory yourself** for likely files and offer them: \`PRD*\`, \`README*\`, \`SPEC*\`/\`spec*\`, design docs, and \`*.md\` under \`docs/\`.`,
|
|
17225
|
+
`3. If the user supplies or approves any, **re-run \`setup\` with \`sources: "<comma-separated file paths>"\`** (local stdio install) so the brief is generated FROM the real doc. Over a hosted/remote connector PAPI can't read local paths \u2014 paste the content into \`description\` instead.`,
|
|
17226
|
+
`If there's no existing doc, skip this and generate the brief from what you know \u2014 the zero-doc path is fully supported.`,
|
|
17227
|
+
""
|
|
17228
|
+
);
|
|
17229
|
+
}
|
|
16622
17230
|
sections.push(
|
|
16623
17231
|
`Generate the outputs below, then call \`setup\` again with:`,
|
|
16624
17232
|
`- \`mode\`: "apply"`,
|
|
@@ -17000,8 +17608,16 @@ function inferCycleFromVersion(version) {
|
|
|
17000
17608
|
const m = version.match(/^v0\.(\d+)\./);
|
|
17001
17609
|
return m ? parseInt(m[1], 10) : 0;
|
|
17002
17610
|
}
|
|
17003
|
-
async function resolveCycleToClose(adapter2, version) {
|
|
17611
|
+
async function resolveCycleToClose(adapter2, version, callerUserId) {
|
|
17004
17612
|
if (adapter2) {
|
|
17613
|
+
if (callerUserId) {
|
|
17614
|
+
try {
|
|
17615
|
+
const mine = callerUserId.trim().toLowerCase();
|
|
17616
|
+
const owned = (await adapter2.readCycles()).filter((c) => c.status === "active" && c.userId?.trim().toLowerCase() === mine).map((c) => c.number);
|
|
17617
|
+
if (owned.length > 0) return Math.max(...owned);
|
|
17618
|
+
} catch {
|
|
17619
|
+
}
|
|
17620
|
+
}
|
|
17005
17621
|
try {
|
|
17006
17622
|
const health = await adapter2.getCycleHealth();
|
|
17007
17623
|
if (health.totalCycles > 0) return health.totalCycles;
|
|
@@ -17010,21 +17626,11 @@ async function resolveCycleToClose(adapter2, version) {
|
|
|
17010
17626
|
}
|
|
17011
17627
|
return inferCycleFromVersion(version);
|
|
17012
17628
|
}
|
|
17013
|
-
async function
|
|
17014
|
-
const collector = new FileWriteCollector();
|
|
17015
|
-
if (!isGitAvailable()) {
|
|
17016
|
-
throw new Error("git is not available.");
|
|
17017
|
-
}
|
|
17018
|
-
if (!isGitRepo(config2.projectRoot)) {
|
|
17019
|
-
throw new Error("not a git repository.");
|
|
17020
|
-
}
|
|
17021
|
-
if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
|
|
17022
|
-
throw new Error("working directory has uncommitted changes. Commit or stash them before releasing.");
|
|
17023
|
-
}
|
|
17629
|
+
async function closeCycleState(config2, adapter2, version, cycleNum, options) {
|
|
17024
17630
|
const warnings = [];
|
|
17025
17631
|
const force = options?.force ?? false;
|
|
17026
17632
|
const skipVersion = options?.skipVersion ?? false;
|
|
17027
|
-
const resolvedCycleNum = cycleNum && cycleNum > 0 ? cycleNum : await resolveCycleToClose(adapter2, version);
|
|
17633
|
+
const resolvedCycleNum = cycleNum && cycleNum > 0 ? cycleNum : await resolveCycleToClose(adapter2, version, options?.callerUserId);
|
|
17028
17634
|
const versionRegexCycle = inferCycleFromVersion(version);
|
|
17029
17635
|
if (resolvedCycleNum > 0 && versionRegexCycle > 0 && versionRegexCycle !== resolvedCycleNum) {
|
|
17030
17636
|
warnings.push(
|
|
@@ -17068,30 +17674,6 @@ To override, pass force=true (emits a telemetry warning).`
|
|
|
17068
17674
|
warnings.push(`Release-readiness check failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
17069
17675
|
}
|
|
17070
17676
|
}
|
|
17071
|
-
if (adapter2) {
|
|
17072
|
-
try {
|
|
17073
|
-
const resolvedBase = resolveBaseBranch(config2.projectRoot, branch);
|
|
17074
|
-
const orphans = listOrphanFeatBranches(config2.projectRoot, resolvedBase);
|
|
17075
|
-
const allDoneOrphans = [];
|
|
17076
|
-
for (const orphanBranch of orphans) {
|
|
17077
|
-
const taskIds = getTaskIdsOnBranch(config2.projectRoot, orphanBranch, resolvedBase);
|
|
17078
|
-
if (taskIds.length === 0) continue;
|
|
17079
|
-
const tasks = await adapter2.getTasks(taskIds);
|
|
17080
|
-
if (tasks.length === 0) continue;
|
|
17081
|
-
const allDone = tasks.every((t) => t.status === "Done");
|
|
17082
|
-
if (allDone) {
|
|
17083
|
-
allDoneOrphans.push(`${orphanBranch} (${taskIds.join(", ")})`);
|
|
17084
|
-
}
|
|
17085
|
-
}
|
|
17086
|
-
if (allDoneOrphans.length > 0) {
|
|
17087
|
-
warnings.push(
|
|
17088
|
-
`Orphan branches detected \u2014 ${allDoneOrphans.length} feature branch(es) have all tasks Done but are not merged into ${resolvedBase}. Merge or delete before next release: ${allDoneOrphans.join("; ")}`
|
|
17089
|
-
);
|
|
17090
|
-
}
|
|
17091
|
-
} catch (err) {
|
|
17092
|
-
warnings.push(`Orphan-branch detection failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
17093
|
-
}
|
|
17094
|
-
}
|
|
17095
17677
|
if (adapter2) {
|
|
17096
17678
|
const currentCycle = resolvedCycleNum;
|
|
17097
17679
|
if (currentCycle > 0) {
|
|
@@ -17115,9 +17697,64 @@ To override, pass force=true (emits a telemetry warning).`
|
|
|
17115
17697
|
const msg = `createCycle (mark complete) failed for cycle ${currentCycle}: ${err instanceof Error ? err.message : String(err)}`;
|
|
17116
17698
|
console.error(`[release] ${msg}`);
|
|
17117
17699
|
throw new Error(
|
|
17118
|
-
`Release blocked \u2014 could not mark cycle ${currentCycle} complete in the DB
|
|
17700
|
+
`Release blocked \u2014 could not mark cycle ${currentCycle} complete in the DB. ${err instanceof Error ? err.message : String(err)}. Resolve the DB error and re-run \`release\`.`
|
|
17119
17701
|
);
|
|
17120
17702
|
}
|
|
17703
|
+
if (adapter2.appendCycleMetrics && adapter2.getBuildReportsSince) {
|
|
17704
|
+
try {
|
|
17705
|
+
const cycleReports = (await adapter2.getBuildReportsSince(currentCycle)).filter((r) => r.cycle === currentCycle);
|
|
17706
|
+
const [snapshot] = computeSnapshotsFromBuildReports(cycleReports);
|
|
17707
|
+
if (snapshot) await adapter2.appendCycleMetrics(snapshot);
|
|
17708
|
+
} catch (err) {
|
|
17709
|
+
console.error(
|
|
17710
|
+
`[release] cycle-metrics snapshot write failed for cycle ${currentCycle} (non-blocking): ${err instanceof Error ? err.message : String(err)}`
|
|
17711
|
+
);
|
|
17712
|
+
}
|
|
17713
|
+
}
|
|
17714
|
+
}
|
|
17715
|
+
}
|
|
17716
|
+
return { resolvedCycleNum, warnings, force, skipVersion };
|
|
17717
|
+
}
|
|
17718
|
+
async function createRelease(config2, branch, version, adapter2, cycleNum, options) {
|
|
17719
|
+
const collector = new FileWriteCollector();
|
|
17720
|
+
if (!isGitAvailable()) {
|
|
17721
|
+
throw new Error("git is not available.");
|
|
17722
|
+
}
|
|
17723
|
+
if (!isGitRepo(config2.projectRoot)) {
|
|
17724
|
+
throw new Error("not a git repository.");
|
|
17725
|
+
}
|
|
17726
|
+
if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
|
|
17727
|
+
throw new Error("working directory has uncommitted changes. Commit or stash them before releasing.");
|
|
17728
|
+
}
|
|
17729
|
+
const { resolvedCycleNum, warnings, skipVersion } = await closeCycleState(
|
|
17730
|
+
config2,
|
|
17731
|
+
adapter2,
|
|
17732
|
+
version,
|
|
17733
|
+
cycleNum,
|
|
17734
|
+
options
|
|
17735
|
+
);
|
|
17736
|
+
if (adapter2) {
|
|
17737
|
+
try {
|
|
17738
|
+
const resolvedBase = resolveBaseBranch(config2.projectRoot, branch);
|
|
17739
|
+
const orphans = listOrphanFeatBranches(config2.projectRoot, resolvedBase);
|
|
17740
|
+
const allDoneOrphans = [];
|
|
17741
|
+
for (const orphanBranch of orphans) {
|
|
17742
|
+
const taskIds = getTaskIdsOnBranch(config2.projectRoot, orphanBranch, resolvedBase);
|
|
17743
|
+
if (taskIds.length === 0) continue;
|
|
17744
|
+
const tasks = await adapter2.getTasks(taskIds);
|
|
17745
|
+
if (tasks.length === 0) continue;
|
|
17746
|
+
const allDone = tasks.every((t) => t.status === "Done");
|
|
17747
|
+
if (allDone) {
|
|
17748
|
+
allDoneOrphans.push(`${orphanBranch} (${taskIds.join(", ")})`);
|
|
17749
|
+
}
|
|
17750
|
+
}
|
|
17751
|
+
if (allDoneOrphans.length > 0) {
|
|
17752
|
+
warnings.push(
|
|
17753
|
+
`\u26A0\uFE0F DATA-INTEGRITY \u2014 ${allDoneOrphans.length} branch(es) have all tasks marked **Done** but were NEVER merged into ${resolvedBase}: their Done status is not backed by git. Merge or delete before next release: ${allDoneOrphans.join("; ")}`
|
|
17754
|
+
);
|
|
17755
|
+
}
|
|
17756
|
+
} catch (err) {
|
|
17757
|
+
warnings.push(`Orphan-branch detection failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
17121
17758
|
}
|
|
17122
17759
|
}
|
|
17123
17760
|
const checkout = checkoutBranch(config2.projectRoot, branch);
|
|
@@ -17148,6 +17785,21 @@ To override, pass force=true (emits a telemetry warning).`
|
|
|
17148
17785
|
}
|
|
17149
17786
|
}
|
|
17150
17787
|
}
|
|
17788
|
+
if (adapter2 && resolvedCycleNum > 0) {
|
|
17789
|
+
try {
|
|
17790
|
+
const stampedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
17791
|
+
const doneCycleTasks = (await adapter2.queryBoard()).filter(
|
|
17792
|
+
(t) => t.cycle === resolvedCycleNum && t.status === "Done"
|
|
17793
|
+
);
|
|
17794
|
+
for (const t of doneCycleTasks) {
|
|
17795
|
+
try {
|
|
17796
|
+
await adapter2.updateTask(t.id, { mergedAt: stampedAt });
|
|
17797
|
+
} catch {
|
|
17798
|
+
}
|
|
17799
|
+
}
|
|
17800
|
+
} catch {
|
|
17801
|
+
}
|
|
17802
|
+
}
|
|
17151
17803
|
const latestTag = getLatestTag(config2.projectRoot);
|
|
17152
17804
|
const changelogPath = join7(config2.projectRoot, "CHANGELOG.md");
|
|
17153
17805
|
if (!latestTag) {
|
|
@@ -17207,60 +17859,6 @@ To override, pass force=true (emits a telemetry warning).`
|
|
|
17207
17859
|
// src/tools/release.ts
|
|
17208
17860
|
init_git();
|
|
17209
17861
|
|
|
17210
|
-
// src/lib/owner-identity.ts
|
|
17211
|
-
function isProjectOwner(callerUserId, ownerUserId) {
|
|
17212
|
-
if (!callerUserId || !ownerUserId) return false;
|
|
17213
|
-
const caller = callerUserId.trim().toLowerCase();
|
|
17214
|
-
const owner = ownerUserId.trim().toLowerCase();
|
|
17215
|
-
if (caller.length === 0 || owner.length === 0) return false;
|
|
17216
|
-
return caller === owner;
|
|
17217
|
-
}
|
|
17218
|
-
async function resolveOwnerGate(adapter2, config2) {
|
|
17219
|
-
if (typeof adapter2.getOwnerIdentity === "function") {
|
|
17220
|
-
try {
|
|
17221
|
-
const identity = await adapter2.getOwnerIdentity();
|
|
17222
|
-
const callerUserId = identity.callerUserId ?? config2.userId ?? null;
|
|
17223
|
-
return {
|
|
17224
|
-
enforced: true,
|
|
17225
|
-
callerIsOwner: isProjectOwner(callerUserId, identity.ownerUserId),
|
|
17226
|
-
callerUserId,
|
|
17227
|
-
ownerUserId: identity.ownerUserId
|
|
17228
|
-
};
|
|
17229
|
-
} catch (err) {
|
|
17230
|
-
return {
|
|
17231
|
-
enforced: true,
|
|
17232
|
-
callerIsOwner: false,
|
|
17233
|
-
callerUserId: config2.userId ?? null,
|
|
17234
|
-
ownerUserId: null,
|
|
17235
|
-
resolutionError: err instanceof Error ? err.message : String(err)
|
|
17236
|
-
};
|
|
17237
|
-
}
|
|
17238
|
-
}
|
|
17239
|
-
if (typeof adapter2.getProjectOwnerUserId === "function") {
|
|
17240
|
-
let ownerUserId = null;
|
|
17241
|
-
let resolutionError;
|
|
17242
|
-
try {
|
|
17243
|
-
ownerUserId = await adapter2.getProjectOwnerUserId();
|
|
17244
|
-
} catch (err) {
|
|
17245
|
-
resolutionError = err instanceof Error ? err.message : String(err);
|
|
17246
|
-
}
|
|
17247
|
-
const callerUserId = config2.userId ?? null;
|
|
17248
|
-
return {
|
|
17249
|
-
enforced: true,
|
|
17250
|
-
callerIsOwner: isProjectOwner(callerUserId, ownerUserId),
|
|
17251
|
-
callerUserId,
|
|
17252
|
-
ownerUserId,
|
|
17253
|
-
...resolutionError !== void 0 ? { resolutionError } : {}
|
|
17254
|
-
};
|
|
17255
|
-
}
|
|
17256
|
-
return {
|
|
17257
|
-
enforced: false,
|
|
17258
|
-
callerIsOwner: true,
|
|
17259
|
-
callerUserId: config2.userId ?? null,
|
|
17260
|
-
ownerUserId: null
|
|
17261
|
-
};
|
|
17262
|
-
}
|
|
17263
|
-
|
|
17264
17862
|
// src/lib/registry-updates.ts
|
|
17265
17863
|
import { execFile } from "child_process";
|
|
17266
17864
|
import { promisify } from "util";
|
|
@@ -17371,21 +17969,32 @@ function parseGithubOwner(input) {
|
|
|
17371
17969
|
function sanitiseBranchSuffix(branch) {
|
|
17372
17970
|
return branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
17373
17971
|
}
|
|
17374
|
-
|
|
17375
|
-
|
|
17376
|
-
|
|
17377
|
-
|
|
17378
|
-
const
|
|
17379
|
-
|
|
17380
|
-
|
|
17381
|
-
|
|
17382
|
-
|
|
17383
|
-
|
|
17384
|
-
})
|
|
17385
|
-
|
|
17386
|
-
|
|
17387
|
-
|
|
17388
|
-
|
|
17972
|
+
var CYCLE_UPDATES_CHANNEL_ENV = "DISCORD_CYCLE_UPDATES_CHANNEL_ID";
|
|
17973
|
+
function buildCycleUpdateCurationDirective(version, cycleClosed) {
|
|
17974
|
+
const channelId = process.env[CYCLE_UPDATES_CHANNEL_ENV]?.trim();
|
|
17975
|
+
if (!channelId) return null;
|
|
17976
|
+
const cyclePart = cycleClosed > 0 ? `Cycle ${cycleClosed}` : "New release";
|
|
17977
|
+
return [
|
|
17978
|
+
"",
|
|
17979
|
+
"---",
|
|
17980
|
+
"## \u{1F4E3} Post a curated cycle update to Discord #papi-cycle-updates",
|
|
17981
|
+
"",
|
|
17982
|
+
`**${cyclePart} \u2014 ${version}** just shipped. Post a curated, safety-gated **rich embed** (NOT plain content, so the link does not unfurl) to the private #papi-cycle-updates channel (id \`${channelId}\`) via \`mcp__discord__send_embed\`.`,
|
|
17983
|
+
"",
|
|
17984
|
+
"**LOCKED TEMPLATE \u2014 every cycle update uses this exact shape so they stay consistent (do NOT improvise the structure):**",
|
|
17985
|
+
`- \`title\`: \`${cyclePart} shipped \u2014 ${version}\``,
|
|
17986
|
+
"- `url`: `https://getpapi.ai/changelog` (makes the title link to the changelog)",
|
|
17987
|
+
"- `color`: `#5a4b8a` (Papi plum)",
|
|
17988
|
+
"- `description`: 4-6 PLAIN one-line bullets (`- ` prefix). No emoji-headed sections, no theme intro paragraph, no sub-headings. Each bullet = one user-facing outcome in the `/patch-notes` voice (warm, plain, outcome-framed). Strip task-IDs and internal framing. Only genuinely user-facing changes \u2014 internal/owner-only/site-plumbing tasks do NOT get a bullet.",
|
|
17989
|
+
`- \`footer\`: \`Built with PAPI \xB7 <N> tasks \xB7 getpapi.ai\` where <N> = the count of THIS cycle's completed tasks (${cyclePart}).`,
|
|
17990
|
+
"",
|
|
17991
|
+
"Then:",
|
|
17992
|
+
"1. Run the build-in-public safety gate: NO external usernames, NO contributor/private work, NO owner cost/commercial data. (#papi-cycle-updates is private \u2014 internal detail is OK \u2014 but keep the gate habit so anything later promoted to public is already clean.)",
|
|
17993
|
+
"2. Show the draft embed for a quick approval BEFORE posting.",
|
|
17994
|
+
`3. On approval, send the embed to channel \`${channelId}\`.`,
|
|
17995
|
+
"",
|
|
17996
|
+
"Skip entirely if nothing user-facing is worth sharing this cycle. Do NOT post to public #changelog per-cycle \u2014 that now happens at strategy-review cadence via /patch-notes."
|
|
17997
|
+
].join("\n");
|
|
17389
17998
|
}
|
|
17390
17999
|
async function postReleaseToX(version, cycleClosed) {
|
|
17391
18000
|
const url = process.env["X_RELEASE_WEBHOOK_URL"]?.trim();
|
|
@@ -17469,14 +18078,48 @@ async function handleRelease(adapter2, config2, args) {
|
|
|
17469
18078
|
version = `${version}-${suffix}`;
|
|
17470
18079
|
}
|
|
17471
18080
|
}
|
|
17472
|
-
|
|
17473
|
-
|
|
17474
|
-
|
|
17475
|
-
|
|
18081
|
+
const tracker = new ProgressTracker("validate-args");
|
|
18082
|
+
try {
|
|
18083
|
+
tracker.mark("owner-identity-guard");
|
|
18084
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
18085
|
+
const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
|
|
18086
|
+
if (branch === productionBaseBranch) {
|
|
18087
|
+
if (gate.enforced && !gate.callerIsOwner) {
|
|
18088
|
+
const resolutionNote = gate.resolutionError ? `
|
|
18089
|
+
|
|
18090
|
+
Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
|
|
18091
|
+
return errorResponse(
|
|
18092
|
+
`Release to ${productionBaseBranch} is restricted to the project owner.
|
|
18093
|
+
|
|
18094
|
+
Your identity does not match this project's owner. If you are a contributor, push your branch and open a PR for the owner to release. If you ARE the owner on a local (pg) setup, set PAPI_USER_ID to your account UUID in .mcp.json; on the hosted/proxy setup your identity comes from your API key \u2014 check you are using YOUR key for YOUR project. Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
|
|
18095
|
+
);
|
|
18096
|
+
}
|
|
18097
|
+
}
|
|
18098
|
+
if (isHostedTransport()) {
|
|
18099
|
+
tracker.mark("hosted-close-cycle");
|
|
18100
|
+
let closed;
|
|
18101
|
+
try {
|
|
18102
|
+
closed = await closeCycleState(config2, adapter2, version, void 0, {
|
|
18103
|
+
force: force ?? false,
|
|
18104
|
+
callerUserId: gate.callerUserId
|
|
18105
|
+
});
|
|
18106
|
+
} catch (err) {
|
|
18107
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
18108
|
+
return errorResponse(message);
|
|
18109
|
+
}
|
|
18110
|
+
const tagAnnotation = `Release ${version}`;
|
|
18111
|
+
const cyclePart = closed.resolvedCycleNum > 0 ? `Cycle ${closed.resolvedCycleNum}` : "The cycle";
|
|
18112
|
+
const warningsBlock = closed.warnings.length > 0 ? `
|
|
18113
|
+
\u26A0\uFE0F Warnings: ${closed.warnings.join("; ")}
|
|
18114
|
+
` : "";
|
|
18115
|
+
return textResponse(
|
|
18116
|
+
`## Release ${version} \u2014 cycle closed in the DB
|
|
17476
18117
|
|
|
17477
|
-
|
|
18118
|
+
${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
|
|
18119
|
+
` + warningsBlock + `
|
|
18120
|
+
The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so the git half of the release (tag, push, CHANGELOG.md) must run on your own machine.
|
|
17478
18121
|
|
|
17479
|
-
|
|
18122
|
+
Finish the release locally:
|
|
17480
18123
|
\`\`\`
|
|
17481
18124
|
git checkout ${branch}
|
|
17482
18125
|
git pull
|
|
@@ -17490,11 +18133,9 @@ If git reports "Author identity unknown" or "Committer identity unknown" (no glo
|
|
|
17490
18133
|
git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
|
|
17491
18134
|
\`\`\`
|
|
17492
18135
|
|
|
17493
|
-
|
|
17494
|
-
|
|
17495
|
-
|
|
17496
|
-
const tracker = new ProgressTracker("validate-args");
|
|
17497
|
-
try {
|
|
18136
|
+
Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`
|
|
18137
|
+
);
|
|
18138
|
+
}
|
|
17498
18139
|
tracker.mark("remote-project-guard");
|
|
17499
18140
|
if (adapter2.getProjectInfo) {
|
|
17500
18141
|
try {
|
|
@@ -17516,21 +18157,6 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
|
|
|
17516
18157
|
console.error("[release] remote-project guard: skipped due to error \u2014 " + (err instanceof Error ? err.message : String(err)));
|
|
17517
18158
|
}
|
|
17518
18159
|
}
|
|
17519
|
-
tracker.mark("owner-identity-guard");
|
|
17520
|
-
const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
|
|
17521
|
-
if (branch === productionBaseBranch) {
|
|
17522
|
-
const gate = await resolveOwnerGate(adapter2, config2);
|
|
17523
|
-
if (gate.enforced && !gate.callerIsOwner) {
|
|
17524
|
-
const resolutionNote = gate.resolutionError ? `
|
|
17525
|
-
|
|
17526
|
-
Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
|
|
17527
|
-
return errorResponse(
|
|
17528
|
-
`Release to ${productionBaseBranch} is restricted to the project owner.
|
|
17529
|
-
|
|
17530
|
-
Your identity does not match this project's owner. If you are a contributor, push your branch and open a PR for the owner to release. If you ARE the owner on a local (pg) setup, set PAPI_USER_ID to your account UUID in .mcp.json; on the hosted/proxy setup your identity comes from your API key \u2014 check you are using YOUR key for YOUR project. Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
|
|
17531
|
-
);
|
|
17532
|
-
}
|
|
17533
|
-
}
|
|
17534
18160
|
tracker.mark("preflight-grouped-branches");
|
|
17535
18161
|
const cycleMatch = version.match(/^v0\.(\d+)\./);
|
|
17536
18162
|
const cycleNumForPreflight = cycleMatch ? parseInt(cycleMatch[1], 10) : void 0;
|
|
@@ -17542,7 +18168,11 @@ Your identity does not match this project's owner. If you are a contributor, pus
|
|
|
17542
18168
|
}
|
|
17543
18169
|
}
|
|
17544
18170
|
tracker.mark("create-release");
|
|
17545
|
-
const result = await createRelease(config2, branch, version, adapter2, void 0, {
|
|
18171
|
+
const result = await createRelease(config2, branch, version, adapter2, void 0, {
|
|
18172
|
+
force: force ?? false,
|
|
18173
|
+
skipVersion: skipVersion ?? false,
|
|
18174
|
+
callerUserId: gate.callerUserId
|
|
18175
|
+
});
|
|
17546
18176
|
const lines = [
|
|
17547
18177
|
`## Release ${result.version}${skipVersion ? " (skip version)" : ""}`,
|
|
17548
18178
|
"",
|
|
@@ -17594,11 +18224,8 @@ Your identity does not match this project's owner. If you are a contributor, pus
|
|
|
17594
18224
|
lines.push("", "\u26A0\uFE0F Dogfood observations could not be saved to DB \u2014 log them manually in DOGFOOD_LOG.md.");
|
|
17595
18225
|
}
|
|
17596
18226
|
}
|
|
17597
|
-
|
|
17598
|
-
|
|
17599
|
-
if (posted) lines.push("", "Posted release announcement to Discord #changelog.");
|
|
17600
|
-
} catch {
|
|
17601
|
-
}
|
|
18227
|
+
const cycleUpdateDirective = buildCycleUpdateCurationDirective(result.version, result.cycleClosed ?? 0);
|
|
18228
|
+
if (cycleUpdateDirective) lines.push(cycleUpdateDirective);
|
|
17602
18229
|
try {
|
|
17603
18230
|
const xPosted = await postReleaseToX(result.version, result.cycleClosed ?? 0);
|
|
17604
18231
|
if (xPosted) lines.push("", "Posted release announcement to X.");
|
|
@@ -17618,7 +18245,7 @@ Your identity does not match this project's owner. If you are a contributor, pus
|
|
|
17618
18245
|
}
|
|
17619
18246
|
} catch {
|
|
17620
18247
|
}
|
|
17621
|
-
lines.push("", `Next: cycle released! Run \`plan\` to start your next
|
|
18248
|
+
lines.push("", `Next: cycle released! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`);
|
|
17622
18249
|
const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
|
|
17623
18250
|
return textResponse(lines.join("\n") + filesToWriteSection);
|
|
17624
18251
|
} catch (err) {
|
|
@@ -17746,6 +18373,16 @@ function selectAutostashPaths(modified, untracked) {
|
|
|
17746
18373
|
const stashableUntracked = untracked.filter((p) => !isDocsPath(p));
|
|
17747
18374
|
return { toStash: [...trackedDirty, ...stashableUntracked], preservedDocs };
|
|
17748
18375
|
}
|
|
18376
|
+
function resolveRelatedDecisions(provided, buildHandoff) {
|
|
18377
|
+
const fromInput = (provided ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
18378
|
+
if (fromInput.length > 0) return { adIds: Array.from(new Set(fromInput)), inferred: false };
|
|
18379
|
+
if (buildHandoff) {
|
|
18380
|
+
const text = typeof buildHandoff === "string" ? buildHandoff : JSON.stringify(buildHandoff);
|
|
18381
|
+
const matches = text.match(/\bAD-\d+\b/g);
|
|
18382
|
+
if (matches && matches.length > 0) return { adIds: Array.from(new Set(matches)), inferred: true };
|
|
18383
|
+
}
|
|
18384
|
+
return { adIds: [], inferred: false };
|
|
18385
|
+
}
|
|
17749
18386
|
var buildStartTimes = /* @__PURE__ */ new Map();
|
|
17750
18387
|
var taskBranchMap = /* @__PURE__ */ new Map();
|
|
17751
18388
|
var taskStartShaMap = /* @__PURE__ */ new Map();
|
|
@@ -17761,16 +18398,36 @@ function sanitisePredictedFiles(raw) {
|
|
|
17761
18398
|
const out = [];
|
|
17762
18399
|
for (const entry of raw) {
|
|
17763
18400
|
if (typeof entry !== "string") continue;
|
|
17764
|
-
const
|
|
17765
|
-
|
|
17766
|
-
const
|
|
17767
|
-
|
|
18401
|
+
for (const segment of entry.split(";")) {
|
|
18402
|
+
const altMatches = segment.match(/\(\s*or\s+`([^`]+)`\s*\)/gi) ?? [];
|
|
18403
|
+
for (const alt of altMatches) {
|
|
18404
|
+
const inner = alt.match(/`([^`]+)`/);
|
|
18405
|
+
if (inner) out.push(inner[1].trim());
|
|
18406
|
+
}
|
|
18407
|
+
const cleaned = segment.replace(/`/g, "").replace(/\s*\(.*$/, "").trim();
|
|
18408
|
+
if (cleaned) out.push(cleaned);
|
|
17768
18409
|
}
|
|
17769
|
-
const cleaned = entry.replace(/`/g, "").replace(/\s*\(.*$/, "").trim();
|
|
17770
|
-
if (cleaned) out.push(cleaned);
|
|
17771
18410
|
}
|
|
17772
18411
|
return Array.from(new Set(out));
|
|
17773
18412
|
}
|
|
18413
|
+
function pathBasename(p) {
|
|
18414
|
+
const parts = p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
|
|
18415
|
+
return parts[parts.length - 1] ?? p;
|
|
18416
|
+
}
|
|
18417
|
+
function isPathInPredictedScope(changedPath, predicted) {
|
|
18418
|
+
const changedLower = changedPath.replace(/\\/g, "/").toLowerCase();
|
|
18419
|
+
const changedBase = pathBasename(changedPath);
|
|
18420
|
+
for (const raw of predicted) {
|
|
18421
|
+
const entry = raw.replace(/\\/g, "/").replace(/\/+$/, "").trim();
|
|
18422
|
+
if (!entry) continue;
|
|
18423
|
+
if (pathBasename(entry) === changedBase) return true;
|
|
18424
|
+
const entryLower = entry.toLowerCase();
|
|
18425
|
+
if (changedLower === entryLower || changedLower.startsWith(`${entryLower}/`)) {
|
|
18426
|
+
return true;
|
|
18427
|
+
}
|
|
18428
|
+
}
|
|
18429
|
+
return false;
|
|
18430
|
+
}
|
|
17774
18431
|
function autoCommit(config2, taskId, taskTitle, predictedFiles) {
|
|
17775
18432
|
const cwd = config2.projectRoot;
|
|
17776
18433
|
const message = `feat(${taskId}): ${taskTitle}`;
|
|
@@ -17800,17 +18457,12 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
|
|
|
17800
18457
|
if (modified.length === 0) {
|
|
17801
18458
|
return "Auto-commit: skipped (no working-tree changes).";
|
|
17802
18459
|
}
|
|
17803
|
-
const basename2 = (p) => {
|
|
17804
|
-
const parts = p.split(/[\\/]/);
|
|
17805
|
-
return parts[parts.length - 1] ?? p;
|
|
17806
|
-
};
|
|
17807
18460
|
const dirname6 = (p) => {
|
|
17808
18461
|
const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
|
17809
18462
|
return idx > 0 ? p.slice(0, idx) : "";
|
|
17810
18463
|
};
|
|
17811
18464
|
const cleanedPredicted = sanitisePredictedFiles(predictedFiles);
|
|
17812
|
-
const
|
|
17813
|
-
const scoped = modified.filter((p) => predictedNames.has(basename2(p)));
|
|
18465
|
+
const scoped = modified.filter((p) => isPathInPredictedScope(p, cleanedPredicted));
|
|
17814
18466
|
if (scoped.length === 0) {
|
|
17815
18467
|
const modSample = modified.slice(0, 5).join(", ");
|
|
17816
18468
|
const predSample = cleanedPredicted.slice(0, 5).join(", ");
|
|
@@ -17938,12 +18590,8 @@ function computeScopeDriftSignal(predicted, changed) {
|
|
|
17938
18590
|
if (!predicted || predicted.length === 0) return null;
|
|
17939
18591
|
const filtered = changed.filter((c) => !isAmbientPath(c));
|
|
17940
18592
|
if (filtered.length === 0) return null;
|
|
17941
|
-
const
|
|
17942
|
-
|
|
17943
|
-
return parts[parts.length - 1] ?? p;
|
|
17944
|
-
};
|
|
17945
|
-
const predictedNames = new Set(predicted.map(basename2).filter(Boolean));
|
|
17946
|
-
const unexpected = filtered.filter((c) => !predictedNames.has(basename2(c)));
|
|
18593
|
+
const cleanedPredicted = sanitisePredictedFiles(predicted);
|
|
18594
|
+
const unexpected = filtered.filter((c) => !isPathInPredictedScope(c, cleanedPredicted));
|
|
17947
18595
|
const fraction = unexpected.length / filtered.length;
|
|
17948
18596
|
const triggered = fraction > 0.5 || unexpected.length > 5;
|
|
17949
18597
|
if (!triggered) return null;
|
|
@@ -18020,6 +18668,14 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
|
|
|
18020
18668
|
if (!task) {
|
|
18021
18669
|
throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
|
|
18022
18670
|
}
|
|
18671
|
+
if (task.assigneeId) {
|
|
18672
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
18673
|
+
if (!gate.callerUserId || gate.callerUserId !== task.assigneeId) {
|
|
18674
|
+
throw new Error(
|
|
18675
|
+
`Task "${taskId}" (${task.title}) is claimed by another member \u2014 only its assignee can build it. Have the claimer build it, or run \`task_unclaim\` to release it first.`
|
|
18676
|
+
);
|
|
18677
|
+
}
|
|
18678
|
+
}
|
|
18023
18679
|
if (task.status === "Done" || task.status === "Archived") {
|
|
18024
18680
|
throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
|
|
18025
18681
|
}
|
|
@@ -18361,10 +19017,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
|
|
|
18361
19017
|
};
|
|
18362
19018
|
buildStartTimes.delete(taskId);
|
|
18363
19019
|
taskBranchMap.delete(taskId);
|
|
18364
|
-
|
|
18365
|
-
|
|
18366
|
-
|
|
18367
|
-
|
|
19020
|
+
const { adIds: relatedAdIds, inferred: relatedInferred } = resolveRelatedDecisions(
|
|
19021
|
+
input.relatedDecisions,
|
|
19022
|
+
task.buildHandoff
|
|
19023
|
+
);
|
|
19024
|
+
if (relatedAdIds.length > 0) report.relatedDecisions = relatedAdIds;
|
|
19025
|
+
console.error(
|
|
19026
|
+
`[build] task ${taskId} related_decisions: ${relatedAdIds.length} AD(s) (${relatedAdIds.length === 0 ? "none" : relatedInferred ? "inferred from handoff" : "builder-provided"}).`
|
|
19027
|
+
);
|
|
18368
19028
|
if (report.startedAt && report.completedAt && typeof adapter2.getToolCallCount === "function") {
|
|
18369
19029
|
try {
|
|
18370
19030
|
const count = await adapter2.getToolCallCount(report.startedAt, report.completedAt);
|
|
@@ -18863,7 +19523,7 @@ var buildExecuteTool = {
|
|
|
18863
19523
|
},
|
|
18864
19524
|
related_decisions: {
|
|
18865
19525
|
type: "string",
|
|
18866
|
-
description: 'Comma-separated AD IDs
|
|
19526
|
+
description: 'Comma-separated AD IDs this build validated or challenged (e.g. "AD-5,AD-12"). Optional but high-value: name any Active Decision your work CONFIRMED, CONTRADICTED, or DEPENDED ON \u2014 this is the primary signal feeding the decision-intelligence graph (validated-decision events). Check the BUILD HANDOFF for AD references. If you leave this empty, AD IDs found in the handoff are auto-captured as a fallback.'
|
|
18867
19527
|
},
|
|
18868
19528
|
handoff_accuracy: {
|
|
18869
19529
|
type: "object",
|
|
@@ -19592,7 +20252,7 @@ function collectDiagnostics(config2) {
|
|
|
19592
20252
|
}
|
|
19593
20253
|
var bugTool = {
|
|
19594
20254
|
name: "bug",
|
|
19595
|
-
description: "Report a bug. Two modes: (1) Default \u2014 creates a Backlog task with severity-based priority for the project board. (2) With report=true \u2014 submits a
|
|
20255
|
+
description: "Report a bug OR submit an idea to PAPI. Two modes: (1) Default \u2014 creates a Backlog task with severity-based priority for the project board. (2) With report=true \u2014 submits a bug or idea (set `type`) upstream to PAPI maintainers with diagnostics, optionally recording notify-when-fixed and contact-ok consent. Use report=true at a friction moment \u2014 e.g. when a PAPI tool returns a workflow-blocking error. Does not call the Anthropic API.",
|
|
19596
20256
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
19597
20257
|
inputSchema: {
|
|
19598
20258
|
type: "object",
|
|
@@ -19626,6 +20286,19 @@ var bugTool = {
|
|
|
19626
20286
|
type: "boolean",
|
|
19627
20287
|
description: "When true, submits a diagnostic bug report with system info instead of creating a board task. Use this to report issues to PAPI maintainers."
|
|
19628
20288
|
},
|
|
20289
|
+
type: {
|
|
20290
|
+
type: "string",
|
|
20291
|
+
enum: ["bug", "idea"],
|
|
20292
|
+
description: 'Report mode only: submission kind \u2014 "bug" (default) or "idea" (feature request / suggestion). Both route to the same upstream PAPI triage.'
|
|
20293
|
+
},
|
|
20294
|
+
notify_when_fixed: {
|
|
20295
|
+
type: "boolean",
|
|
20296
|
+
description: "Report mode only: set true if the user wants to be notified when this submission is resolved (surfaced in a later orient/MCP call and on their dashboard)."
|
|
20297
|
+
},
|
|
20298
|
+
contact_ok: {
|
|
20299
|
+
type: "boolean",
|
|
20300
|
+
description: "Report mode only: set true if the user consents to the PAPI team reaching out about this submission."
|
|
20301
|
+
},
|
|
19629
20302
|
project: {
|
|
19630
20303
|
type: "string",
|
|
19631
20304
|
description: "Project id (UUID) or slug to file this bug under, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default."
|
|
@@ -19643,6 +20316,9 @@ async function handleBug(adapter2, config2, args) {
|
|
|
19643
20316
|
if (!adapter2.submitBugReport) {
|
|
19644
20317
|
return errorResponse("Bug reports require a database adapter (pg or proxy). The md adapter does not support bug reports.");
|
|
19645
20318
|
}
|
|
20319
|
+
const type = args.type === "idea" ? "idea" : "bug";
|
|
20320
|
+
const notifyRequested = args.notify_when_fixed === true;
|
|
20321
|
+
const contactOk = args.contact_ok === true;
|
|
19646
20322
|
const diagnostics = collectDiagnostics(config2);
|
|
19647
20323
|
const notes = args.notes?.trim();
|
|
19648
20324
|
if (notes) {
|
|
@@ -19658,17 +20334,27 @@ async function handleBug(adapter2, config2, args) {
|
|
|
19658
20334
|
}
|
|
19659
20335
|
const report = await adapter2.submitBugReport({
|
|
19660
20336
|
projectId,
|
|
20337
|
+
type,
|
|
19661
20338
|
description: text,
|
|
19662
20339
|
diagnostics,
|
|
19663
|
-
status: "open"
|
|
20340
|
+
status: "open",
|
|
20341
|
+
notifyRequested,
|
|
20342
|
+
contactOk
|
|
19664
20343
|
});
|
|
20344
|
+
const kindLabel = type === "idea" ? "Idea" : "Bug report";
|
|
20345
|
+
const followUps = [];
|
|
20346
|
+
if (notifyRequested) followUps.push("you'll be notified when it's resolved");
|
|
20347
|
+
if (contactOk) followUps.push("the PAPI team may reach out");
|
|
20348
|
+
const followUpLine = followUps.length ? `
|
|
20349
|
+
|
|
20350
|
+
Noted: ${followUps.join("; ")}.` : "";
|
|
19665
20351
|
return textResponse(
|
|
19666
|
-
|
|
20352
|
+
`**${kindLabel} submitted** \u2014 ID: \`${report.id}\`
|
|
19667
20353
|
|
|
19668
|
-
Description: ${text}
|
|
20354
|
+
${type === "idea" ? "Idea" : "Description"}: ${text}
|
|
19669
20355
|
Diagnostics collected: Node ${diagnostics.nodeVersion}, ${diagnostics.platform}/${diagnostics.arch}, adapter=${diagnostics.adapterType}
|
|
19670
20356
|
|
|
19671
|
-
This
|
|
20357
|
+
This ${type} is visible to PAPI maintainers.${followUpLine}`
|
|
19672
20358
|
);
|
|
19673
20359
|
}
|
|
19674
20360
|
let target = adapter2;
|
|
@@ -20045,7 +20731,7 @@ async function prepareReconcile(adapter2, projectRoot) {
|
|
|
20045
20731
|
const misaligned = allTasks.filter((t) => {
|
|
20046
20732
|
const mapping = phaseStageMap.get(t.phase ?? "");
|
|
20047
20733
|
if (!mapping) return false;
|
|
20048
|
-
return mapping.stage.status === "
|
|
20734
|
+
return mapping.stage.status === "Deferred" || mapping.horizon.status === "Deferred";
|
|
20049
20735
|
});
|
|
20050
20736
|
if (misaligned.length > 0) {
|
|
20051
20737
|
lines.push("### Hierarchy Misalignment (tasks in deferred stages/horizons)");
|
|
@@ -20643,108 +21329,29 @@ Re-run build_execute complete with a production_verification field, then re-subm
|
|
|
20643
21329
|
const handoffRegenerated = false;
|
|
20644
21330
|
let handoffRegenPrompt;
|
|
20645
21331
|
if (input.stage === "handoff-review" && input.verdict === "request-changes" && task.buildHandoff) {
|
|
20646
|
-
handoffRegenPrompt = await prepareHandoffRegen(task, input.comments, adapter2);
|
|
20647
|
-
}
|
|
20648
|
-
const stageLabel = input.stage === "handoff-review" ? "Handoff Review" : "Build Acceptance";
|
|
20649
|
-
let phaseChanges = [];
|
|
20650
|
-
if (newStatus) {
|
|
20651
|
-
try {
|
|
20652
|
-
phaseChanges = await propagatePhaseStatus(adapter2);
|
|
20653
|
-
} catch {
|
|
20654
|
-
}
|
|
20655
|
-
}
|
|
20656
|
-
return {
|
|
20657
|
-
stageLabel,
|
|
20658
|
-
taskId: input.taskId,
|
|
20659
|
-
verdict: input.verdict,
|
|
20660
|
-
comments: input.comments.trim(),
|
|
20661
|
-
newStatus,
|
|
20662
|
-
unblockedTasks,
|
|
20663
|
-
handoffRegenerated,
|
|
20664
|
-
handoffRegenPrompt,
|
|
20665
|
-
currentCycle: cycle,
|
|
20666
|
-
phaseChanges,
|
|
20667
|
-
closedDocActions
|
|
20668
|
-
};
|
|
20669
|
-
}
|
|
20670
|
-
|
|
20671
|
-
// src/services/session-guidance.ts
|
|
20672
|
-
var state = {
|
|
20673
|
-
toolCallCount: 0,
|
|
20674
|
-
lastOrientAt: null,
|
|
20675
|
-
releaseSinceLastOrient: false,
|
|
20676
|
-
sessionStartedAt: Date.now(),
|
|
20677
|
-
lastReviewListAt: null,
|
|
20678
|
-
failureTimestamps: [],
|
|
20679
|
-
consecutiveFailures: 0
|
|
20680
|
-
};
|
|
20681
|
-
var CONTEXT_BLOAT_CALL_THRESHOLD = 40;
|
|
20682
|
-
var ORIENT_GAP_MS = 3 * 60 * 60 * 1e3;
|
|
20683
|
-
var REVIEW_LIST_GUARD_WINDOW_MS = 15 * 60 * 1e3;
|
|
20684
|
-
var FAILURE_WINDOW_MS = 30 * 60 * 1e3;
|
|
20685
|
-
var FAILURE_RATE_THRESHOLD = 8;
|
|
20686
|
-
var CONSECUTIVE_FAILURE_THRESHOLD = 4;
|
|
20687
|
-
function recordToolCall(name) {
|
|
20688
|
-
state.toolCallCount++;
|
|
20689
|
-
if (name === "release") state.releaseSinceLastOrient = true;
|
|
20690
|
-
if (name === "review_list") state.lastReviewListAt = Date.now();
|
|
20691
|
-
}
|
|
20692
|
-
function recordToolOutcome(success) {
|
|
20693
|
-
if (success) {
|
|
20694
|
-
state.consecutiveFailures = 0;
|
|
20695
|
-
return;
|
|
20696
|
-
}
|
|
20697
|
-
const now = Date.now();
|
|
20698
|
-
state.consecutiveFailures++;
|
|
20699
|
-
state.failureTimestamps.push(now);
|
|
20700
|
-
const cutoff = now - FAILURE_WINDOW_MS;
|
|
20701
|
-
state.failureTimestamps = state.failureTimestamps.filter((t) => t >= cutoff);
|
|
20702
|
-
}
|
|
20703
|
-
function wasReviewListSeenRecently(windowMs = REVIEW_LIST_GUARD_WINDOW_MS) {
|
|
20704
|
-
if (state.lastReviewListAt == null) return false;
|
|
20705
|
-
return Date.now() - state.lastReviewListAt <= windowMs;
|
|
20706
|
-
}
|
|
20707
|
-
function markOrient() {
|
|
20708
|
-
state.lastOrientAt = Date.now();
|
|
20709
|
-
state.releaseSinceLastOrient = false;
|
|
20710
|
-
}
|
|
20711
|
-
function getProjectConnectionBanner(projectName, projectSlug) {
|
|
20712
|
-
if (!projectName || !projectSlug) return null;
|
|
20713
|
-
return `[Connected: ${projectName} (${projectSlug})] \u2014 confirm this is the project you mean before I write to it. If it's wrong, don't proceed: pass \`project=<id>\` on the call to target a different project, or fix the project id in your MCP config (PAPI_PROJECT_ID for local, x-papi-project-id header for remote) and reconnect.`;
|
|
20714
|
-
}
|
|
20715
|
-
function detectContextDegradation(now = Date.now()) {
|
|
20716
|
-
if (state.consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
|
20717
|
-
return `Context degradation (clash): ${state.consecutiveFailures} tool calls failed in a row \u2014 the session may be stuck on a contradiction. Consider a fresh window after this task.`;
|
|
20718
|
-
}
|
|
20719
|
-
const cutoff = now - FAILURE_WINDOW_MS;
|
|
20720
|
-
const recentFailures = state.failureTimestamps.filter((t) => t >= cutoff).length;
|
|
20721
|
-
if (recentFailures >= FAILURE_RATE_THRESHOLD) {
|
|
20722
|
-
const mins = Math.round(FAILURE_WINDOW_MS / 6e4);
|
|
20723
|
-
return `Context degradation (distraction/confusion): ${recentFailures} tool failures in the last ${mins}min. A fresh window often clears this faster than pushing on.`;
|
|
20724
|
-
}
|
|
20725
|
-
return null;
|
|
20726
|
-
}
|
|
20727
|
-
async function buildSessionGuidance() {
|
|
20728
|
-
const signals = [];
|
|
20729
|
-
const degradation = detectContextDegradation();
|
|
20730
|
-
if (degradation) signals.push(degradation);
|
|
20731
|
-
if (state.toolCallCount > CONTEXT_BLOAT_CALL_THRESHOLD) {
|
|
20732
|
-
signals.push(
|
|
20733
|
-
`${state.toolCallCount} tool calls this session \u2014 context may be bloated. Consider starting a fresh window.`
|
|
20734
|
-
);
|
|
20735
|
-
}
|
|
20736
|
-
if (state.lastOrientAt && Date.now() - state.lastOrientAt > ORIENT_GAP_MS) {
|
|
20737
|
-
const hours = Math.round((Date.now() - state.lastOrientAt) / (60 * 60 * 1e3));
|
|
20738
|
-
signals.push(
|
|
20739
|
-
`${hours}h since last orient \u2014 session may be stale. Consider a fresh window for best results.`
|
|
20740
|
-
);
|
|
21332
|
+
handoffRegenPrompt = await prepareHandoffRegen(task, input.comments, adapter2);
|
|
20741
21333
|
}
|
|
20742
|
-
|
|
20743
|
-
|
|
20744
|
-
|
|
20745
|
-
|
|
21334
|
+
const stageLabel = input.stage === "handoff-review" ? "Handoff Review" : "Build Acceptance";
|
|
21335
|
+
let phaseChanges = [];
|
|
21336
|
+
if (newStatus) {
|
|
21337
|
+
try {
|
|
21338
|
+
phaseChanges = await propagatePhaseStatus(adapter2);
|
|
21339
|
+
} catch {
|
|
21340
|
+
}
|
|
20746
21341
|
}
|
|
20747
|
-
return
|
|
21342
|
+
return {
|
|
21343
|
+
stageLabel,
|
|
21344
|
+
taskId: input.taskId,
|
|
21345
|
+
verdict: input.verdict,
|
|
21346
|
+
comments: input.comments.trim(),
|
|
21347
|
+
newStatus,
|
|
21348
|
+
unblockedTasks,
|
|
21349
|
+
handoffRegenerated,
|
|
21350
|
+
handoffRegenPrompt,
|
|
21351
|
+
currentCycle: cycle,
|
|
21352
|
+
phaseChanges,
|
|
21353
|
+
closedDocActions
|
|
21354
|
+
};
|
|
20748
21355
|
}
|
|
20749
21356
|
|
|
20750
21357
|
// src/tools/review.ts
|
|
@@ -20893,6 +21500,11 @@ function formatReviewList(pendingBuilds) {
|
|
|
20893
21500
|
for (const t of pendingBuilds) {
|
|
20894
21501
|
lines.push(`- **${t.id}:** ${t.title}`);
|
|
20895
21502
|
lines.push(` Status: ${t.status} | Priority: ${t.priority} | Complexity: ${t.complexity}`);
|
|
21503
|
+
if (t.assigneeId || t.reviewerId) {
|
|
21504
|
+
const built = t.assigneeId ? `built by ${t.assigneeId}` : "unattributed build";
|
|
21505
|
+
const review = t.reviewerId ? `claimed by reviewer ${t.reviewerId}` : "unclaimed \u2014 `review_claim` to take it";
|
|
21506
|
+
lines.push(` ${built} \xB7 ${review}`);
|
|
21507
|
+
}
|
|
20896
21508
|
}
|
|
20897
21509
|
lines.push("");
|
|
20898
21510
|
lines.push('Use `review_submit` with task_id, stage "build-acceptance", verdict, and comments \u2014 are you happy with the build, or would you like changes?');
|
|
@@ -20946,6 +21558,10 @@ function mergeAfterAccept(config2, taskId) {
|
|
|
20946
21558
|
}
|
|
20947
21559
|
const merge = mergePullRequest(config2.projectRoot, featureBranch);
|
|
20948
21560
|
if (!merge.success) {
|
|
21561
|
+
if (isBranchMergedInto(config2.projectRoot, featureBranch, baseBranch)) {
|
|
21562
|
+
details.push(`Branch '${featureBranch}' is already merged into '${baseBranch}'.`);
|
|
21563
|
+
return { merged: true, skipped: false, message: `Already merged: \`${featureBranch}\` \u2192 \`${baseBranch}\`.`, details };
|
|
21564
|
+
}
|
|
20949
21565
|
return { merged: false, skipped: false, message: `PR merge failed: ${merge.message}`, details };
|
|
20950
21566
|
}
|
|
20951
21567
|
details.push(merge.message);
|
|
@@ -21070,12 +21686,28 @@ async function handleReviewSubmit(adapter2, config2, args) {
|
|
|
21070
21686
|
}
|
|
21071
21687
|
if (stage === "build-acceptance" && verdict === "accept") {
|
|
21072
21688
|
const reviewerConfirmed = args.reviewer_confirmed === true;
|
|
21073
|
-
if (!reviewerConfirmed && !wasReviewListSeenRecently()) {
|
|
21689
|
+
if (!reviewerConfirmed && !wasReviewListSeenRecently(void 0, callerKeyFromConfig(config2))) {
|
|
21074
21690
|
return errorResponse(
|
|
21075
21691
|
"REVIEW_LIST_REQUIRED \u2014 call review_list first to see what is pending, then submit review_submit with reviewer_confirmed: true after reviewing the build report. (Guards against accepting work without inspecting it \u2014 see SUP-2026-010.)"
|
|
21076
21692
|
);
|
|
21077
21693
|
}
|
|
21078
21694
|
}
|
|
21695
|
+
if (stage === "build-acceptance") {
|
|
21696
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
21697
|
+
const caller = gate.callerUserId;
|
|
21698
|
+
const reviewTask = await adapter2.getTask(taskId);
|
|
21699
|
+
if (reviewTask?.reviewerId && caller && reviewTask.reviewerId !== caller && !gate.callerIsOwner) {
|
|
21700
|
+
return errorResponse(
|
|
21701
|
+
`Task "${taskId}" is claimed for review by another member (${reviewTask.reviewerId}). Only the claiming reviewer (or the project owner) can submit its verdict.`
|
|
21702
|
+
);
|
|
21703
|
+
}
|
|
21704
|
+
if (reviewTask && !reviewTask.reviewerId && caller) {
|
|
21705
|
+
try {
|
|
21706
|
+
await adapter2.updateTask(taskId, { reviewerId: caller });
|
|
21707
|
+
} catch {
|
|
21708
|
+
}
|
|
21709
|
+
}
|
|
21710
|
+
}
|
|
21079
21711
|
const tracker = new ProgressTracker("validate-args");
|
|
21080
21712
|
const handoffRegenResponse = args.handoff_regen_response;
|
|
21081
21713
|
if (handoffRegenResponse?.trim()) {
|
|
@@ -21123,6 +21755,7 @@ ${result.handoffRegenPrompt.userMessage}
|
|
|
21123
21755
|
}
|
|
21124
21756
|
let mergeNote = "";
|
|
21125
21757
|
let overlapNote = "";
|
|
21758
|
+
let mergeFailed = false;
|
|
21126
21759
|
if (stage === "build-acceptance" && verdict === "accept") {
|
|
21127
21760
|
const mergeResult = mergeAfterAccept(config2, taskId);
|
|
21128
21761
|
if (mergeResult.skipped) {
|
|
@@ -21132,14 +21765,24 @@ ${result.handoffRegenPrompt.userMessage}
|
|
|
21132
21765
|
> ${mergeResult.message}`;
|
|
21133
21766
|
}
|
|
21134
21767
|
} else if (!mergeResult.merged) {
|
|
21768
|
+
mergeFailed = true;
|
|
21769
|
+
let revertNote = "";
|
|
21770
|
+
try {
|
|
21771
|
+
await adapter2.updateTask(taskId, { status: "In Review" });
|
|
21772
|
+
revertNote = ` Task rolled back to **In Review** \u2014 re-run \`review_submit ${taskId} build-acceptance accept\` once the merge succeeds.`;
|
|
21773
|
+
} catch (err) {
|
|
21774
|
+
revertNote = ` \u26A0\uFE0F Could not auto-revert the status (${err instanceof Error ? err.message : String(err)}) \u2014 set ${taskId} back to In Review manually so the board does not show it as Done.`;
|
|
21775
|
+
}
|
|
21135
21776
|
mergeNote = `
|
|
21136
21777
|
|
|
21137
|
-
\u26A0\uFE0F **PR merge failed
|
|
21138
|
-
|
|
21139
|
-
${mergeResult.message}
|
|
21778
|
+
\u26A0\uFE0F **PR merge failed \u2014 task NOT marked Done.**${revertNote}
|
|
21140
21779
|
|
|
21141
|
-
|
|
21780
|
+
${mergeResult.message}`;
|
|
21142
21781
|
} else {
|
|
21782
|
+
try {
|
|
21783
|
+
await adapter2.updateTask(taskId, { mergedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
21784
|
+
} catch {
|
|
21785
|
+
}
|
|
21143
21786
|
const detailLines = mergeResult.details.map((l) => `> ${l}`).join("\n");
|
|
21144
21787
|
mergeNote = `
|
|
21145
21788
|
|
|
@@ -21159,7 +21802,7 @@ ${overlap}`;
|
|
|
21159
21802
|
}
|
|
21160
21803
|
let autoReleaseNote = "";
|
|
21161
21804
|
let batchSummaryNote = "";
|
|
21162
|
-
if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0) {
|
|
21805
|
+
if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0 && !mergeFailed) {
|
|
21163
21806
|
try {
|
|
21164
21807
|
const allTasks = await adapter2.queryBoard();
|
|
21165
21808
|
const cycleTasks = allTasks.filter((t) => t.cycle === result.currentCycle);
|
|
@@ -21252,9 +21895,28 @@ Resolve the issue above, then run \`release\` manually.`;
|
|
|
21252
21895
|
}
|
|
21253
21896
|
}
|
|
21254
21897
|
const phaseNote = result.phaseChanges.length > 0 ? "\n\n" + result.phaseChanges.map((c) => `Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`).join("\n") : "";
|
|
21255
|
-
|
|
21898
|
+
let autoReviewNote = "";
|
|
21899
|
+
if (autoReview) {
|
|
21900
|
+
const findingLines = autoReview.findings.slice(0, 10).map((f) => {
|
|
21901
|
+
const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
|
|
21902
|
+
return ` - [${f.severity}]${loc} ${f.message}`;
|
|
21903
|
+
}).join("\n");
|
|
21904
|
+
const more = autoReview.findings.length > 10 ? `
|
|
21905
|
+
\u2026and ${autoReview.findings.length - 10} more` : "";
|
|
21906
|
+
autoReviewNote = `
|
|
21907
|
+
|
|
21908
|
+
**Quality Gate (auto-review): ${autoReview.verdict}** \u2014 ${autoReview.summary}` + (autoReview.findings.length > 0 ? `
|
|
21909
|
+
${findingLines}${more}` : "");
|
|
21910
|
+
if (stage === "build-acceptance" && verdict === "accept" && autoReview.verdict !== "pass") {
|
|
21911
|
+
autoReviewNote += `
|
|
21256
21912
|
|
|
21257
|
-
**
|
|
21913
|
+
\u26A0\uFE0F **Override recorded:** ${reviewer} accepted past a ${autoReview.verdict} quality gate (${autoReview.findings.length} finding${autoReview.findings.length === 1 ? "" : "s"}). The verdict + findings are stored on this review for the audit trail.`;
|
|
21914
|
+
}
|
|
21915
|
+
} else if (stage === "build-acceptance" && verdict === "accept") {
|
|
21916
|
+
autoReviewNote = `
|
|
21917
|
+
|
|
21918
|
+
**Quality Gate:** no auto-review attached. PAPI's standard pre-accept step is a code review of the branch diff \u2014 run \`review_submit\` with \`dispatch:"subagent"\` to auto-review, or attach \`auto_review\` findings. Risk-tier work (auth, data, migrations, CI) should always carry one.`;
|
|
21919
|
+
}
|
|
21258
21920
|
let nextStepNote = "";
|
|
21259
21921
|
if (!autoReleaseNote && stage === "build-acceptance") {
|
|
21260
21922
|
if (verdict === "accept") {
|
|
@@ -21290,6 +21952,66 @@ ${statusNote}${autoReviewNote}${unblockNote}${docClosureNote}${regenNote}${merge
|
|
|
21290
21952
|
}));
|
|
21291
21953
|
}
|
|
21292
21954
|
}
|
|
21955
|
+
var reviewClaimTool = {
|
|
21956
|
+
name: "review_claim",
|
|
21957
|
+
description: "Claim a Pending Review from the shared cross-user review queue so you are the one reviewing it. Atomic first-claim-wins \u2014 two reviewers cannot grab the same build. After claiming, run review_submit to record your verdict. Owner-or-active-member only. Does not call the Anthropic API.",
|
|
21958
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
21959
|
+
inputSchema: {
|
|
21960
|
+
type: "object",
|
|
21961
|
+
properties: {
|
|
21962
|
+
task_id: { type: "string", description: 'The In Review task to claim for review, e.g. "task-2072".' }
|
|
21963
|
+
},
|
|
21964
|
+
required: ["task_id"]
|
|
21965
|
+
}
|
|
21966
|
+
};
|
|
21967
|
+
async function resolveReviewerIdentity(adapter2, config2) {
|
|
21968
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
21969
|
+
const callerUserId = gate.callerUserId;
|
|
21970
|
+
if (!callerUserId) {
|
|
21971
|
+
const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
|
|
21972
|
+
return { error: "Reviewing requires a resolvable user identity, but none was found" + note + ". Set PAPI_USER_ID to your account UUID (local) \u2014 hosted sessions derive it from the bearer token." };
|
|
21973
|
+
}
|
|
21974
|
+
if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
|
|
21975
|
+
try {
|
|
21976
|
+
const members = await adapter2.listContributors();
|
|
21977
|
+
if (!members.some((m) => m.userId === callerUserId)) {
|
|
21978
|
+
return { error: "Only the project owner or an active contributor can claim reviews on this project." };
|
|
21979
|
+
}
|
|
21980
|
+
} catch (err) {
|
|
21981
|
+
return { error: `Could not verify membership: ${err instanceof Error ? err.message : String(err)}` };
|
|
21982
|
+
}
|
|
21983
|
+
}
|
|
21984
|
+
return { callerUserId, callerIsOwner: gate.callerIsOwner };
|
|
21985
|
+
}
|
|
21986
|
+
async function handleReviewClaim(adapter2, config2, args) {
|
|
21987
|
+
const taskId = typeof args.task_id === "string" ? args.task_id.trim() : "";
|
|
21988
|
+
if (!taskId) return errorResponse('A task_id is required. Example: review_claim task_id="task-2072"');
|
|
21989
|
+
if (typeof adapter2.claimReview !== "function") {
|
|
21990
|
+
return errorResponse("The shared review queue is not available on this adapter.");
|
|
21991
|
+
}
|
|
21992
|
+
const identity = await resolveReviewerIdentity(adapter2, config2);
|
|
21993
|
+
if ("error" in identity) return errorResponse(identity.error);
|
|
21994
|
+
const me = identity.callerUserId;
|
|
21995
|
+
const claimed = await adapter2.claimReview(taskId, me);
|
|
21996
|
+
if (!claimed) {
|
|
21997
|
+
const task = await adapter2.getTask(taskId);
|
|
21998
|
+
if (!task) return errorResponse(`Task "${taskId}" not found on the board.`);
|
|
21999
|
+
if (task.status !== "In Review") {
|
|
22000
|
+
return errorResponse(`Task "${taskId}" is ${task.status}, not In Review \u2014 there is no pending review to claim.`);
|
|
22001
|
+
}
|
|
22002
|
+
if (task.reviewerId && task.reviewerId !== me) {
|
|
22003
|
+
return errorResponse(`Task "${taskId}" is already being reviewed by another member (${task.reviewerId}).`);
|
|
22004
|
+
}
|
|
22005
|
+
if (task.reviewerId === me) {
|
|
22006
|
+
return textResponse(`You have already claimed the review for **${taskId}** \u2014 run \`review_submit\` to record your verdict.`);
|
|
22007
|
+
}
|
|
22008
|
+
return errorResponse(`Could not claim the review for "${taskId}".`);
|
|
22009
|
+
}
|
|
22010
|
+
const builtBy = claimed.assigneeId ? ` (built by ${claimed.assigneeId})` : "";
|
|
22011
|
+
return textResponse(
|
|
22012
|
+
`\u2705 Claimed the review for **${claimed.id}** (${claimed.title})${builtBy}. You are now the reviewer. Read the build report, then run \`review_submit\` with your verdict.`
|
|
22013
|
+
);
|
|
22014
|
+
}
|
|
21293
22015
|
|
|
21294
22016
|
// src/tools/init.ts
|
|
21295
22017
|
import { randomUUID as randomUUID15 } from "crypto";
|
|
@@ -22023,7 +22745,7 @@ ${lines.join("\n")}`;
|
|
|
22023
22745
|
|
|
22024
22746
|
// src/lib/version-handshake.ts
|
|
22025
22747
|
var MIN_PROXY_VERSION = "1.0.0";
|
|
22026
|
-
var SERVER_VERSION = "0.7.
|
|
22748
|
+
var SERVER_VERSION = "0.7.35";
|
|
22027
22749
|
function meetsMinVersion(actual, required) {
|
|
22028
22750
|
const actualParts = actual.split(".").map((n) => parseInt(n, 10));
|
|
22029
22751
|
const requiredParts = required.split(".").map((n) => parseInt(n, 10));
|
|
@@ -22405,13 +23127,29 @@ function normalizeDocPath(rawPath, projectRoot) {
|
|
|
22405
23127
|
error: `doc_register received an absolute path: \`${input}\`. Pass a path relative to your project root instead (e.g. \`docs/research/x.md\`). Absolute paths break visibility inference and on-disk lookups.`
|
|
22406
23128
|
};
|
|
22407
23129
|
}
|
|
23130
|
+
function docRegisterSoftFail(adapterType, lastStep, error, hint) {
|
|
23131
|
+
const payload = { tool: "doc_register", adapter: adapterType, lastStep, error, hint };
|
|
23132
|
+
return textResponse(
|
|
23133
|
+
`\u26A0\uFE0F doc_register skipped (non-blocking) at step "${lastStep}" \u2014 your build/plan/review flow can continue safely; document registration is advisory.
|
|
23134
|
+
|
|
23135
|
+
Diagnostic JSON:
|
|
23136
|
+
${JSON.stringify(payload, null, 2)}`
|
|
23137
|
+
);
|
|
23138
|
+
}
|
|
22408
23139
|
async function handleDocRegister(adapter2, args, config2) {
|
|
23140
|
+
const adapterType = config2?.adapterType ?? "unknown";
|
|
23141
|
+
const continueHint = "doc_register is advisory \u2014 your build/plan/review flow is unaffected. Fix the input (or wait for the registry to recover) and re-run doc_register; or just continue without it.";
|
|
22409
23142
|
if (!adapter2.registerDoc) {
|
|
22410
|
-
return
|
|
23143
|
+
return docRegisterSoftFail(
|
|
23144
|
+
adapterType,
|
|
23145
|
+
"adapter-capability-check",
|
|
23146
|
+
"Doc registry not available \u2014 requires the pg/proxy adapter.",
|
|
23147
|
+
"On the md adapter doc_register is a no-op. Continue without it \u2014 nothing is blocked."
|
|
23148
|
+
);
|
|
22411
23149
|
}
|
|
22412
23150
|
const normalized = normalizeDocPath(args.path, config2?.projectRoot);
|
|
22413
23151
|
if ("error" in normalized) {
|
|
22414
|
-
return
|
|
23152
|
+
return docRegisterSoftFail(adapterType, "path-normalization", normalized.error, continueHint);
|
|
22415
23153
|
}
|
|
22416
23154
|
const path7 = normalized.path;
|
|
22417
23155
|
const title = args.title;
|
|
@@ -22424,39 +23162,49 @@ async function handleDocRegister(adapter2, args, config2) {
|
|
|
22424
23162
|
const supersededByPath = args.superseded_by_path;
|
|
22425
23163
|
const visibility = resolveDocVisibility(args.visibility, path7);
|
|
22426
23164
|
if (!path7 || !title || !type || !summary || !cycle) {
|
|
22427
|
-
return
|
|
23165
|
+
return docRegisterSoftFail(
|
|
23166
|
+
adapterType,
|
|
23167
|
+
"field-validation",
|
|
23168
|
+
"Required fields: path, title, type, summary, cycle.",
|
|
23169
|
+
continueHint
|
|
23170
|
+
);
|
|
22428
23171
|
}
|
|
22429
|
-
|
|
22430
|
-
|
|
22431
|
-
|
|
22432
|
-
|
|
22433
|
-
|
|
22434
|
-
|
|
23172
|
+
try {
|
|
23173
|
+
let supersededBy;
|
|
23174
|
+
if (supersededByPath) {
|
|
23175
|
+
const existing = await adapter2.getDoc?.(supersededByPath);
|
|
23176
|
+
if (existing) {
|
|
23177
|
+
supersededBy = existing.id;
|
|
23178
|
+
await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
|
|
23179
|
+
}
|
|
22435
23180
|
}
|
|
22436
|
-
|
|
22437
|
-
|
|
22438
|
-
|
|
22439
|
-
|
|
22440
|
-
|
|
22441
|
-
|
|
22442
|
-
|
|
22443
|
-
|
|
22444
|
-
|
|
22445
|
-
|
|
22446
|
-
|
|
22447
|
-
|
|
22448
|
-
|
|
22449
|
-
|
|
22450
|
-
|
|
22451
|
-
|
|
22452
|
-
`**Registered:** ${entry.title}
|
|
23181
|
+
const entry = await adapter2.registerDoc({
|
|
23182
|
+
title,
|
|
23183
|
+
type,
|
|
23184
|
+
path: path7,
|
|
23185
|
+
status,
|
|
23186
|
+
summary,
|
|
23187
|
+
tags,
|
|
23188
|
+
cycleCreated: cycle,
|
|
23189
|
+
cycleUpdated: cycle,
|
|
23190
|
+
supersededBy,
|
|
23191
|
+
actions,
|
|
23192
|
+
visibility
|
|
23193
|
+
});
|
|
23194
|
+
const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
|
|
23195
|
+
return textResponse(
|
|
23196
|
+
`**Registered:** ${entry.title}
|
|
22453
23197
|
- **Path:** ${entry.path}
|
|
22454
23198
|
- **Type:** ${entry.type} | **Status:** ${entry.status}
|
|
22455
23199
|
- **Visibility:** ${visibilityLabel}
|
|
22456
23200
|
- **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
|
|
22457
23201
|
- **Actions:** ${actions?.length ?? 0} items
|
|
22458
23202
|
- **ID:** ${entry.id}`
|
|
22459
|
-
|
|
23203
|
+
);
|
|
23204
|
+
} catch (err) {
|
|
23205
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
23206
|
+
return docRegisterSoftFail(adapterType, "registry-write", message, continueHint);
|
|
23207
|
+
}
|
|
22460
23208
|
}
|
|
22461
23209
|
async function handleDocSearch(adapter2, args, config2) {
|
|
22462
23210
|
if (!adapter2.searchDocs) {
|
|
@@ -22807,6 +23555,10 @@ var orientTool = {
|
|
|
22807
23555
|
deep_housekeeping: {
|
|
22808
23556
|
type: "boolean",
|
|
22809
23557
|
description: "Run expensive cross-referencing checks: board-vs-branch reconciliation, unrecorded commit detection, unregistered doc scan. Default false \u2014 orient stays fast and noise-light at session start. Pass true when you specifically want a sweep (e.g. before release or after a long break). One-liner in default output points users at this flag when they need it."
|
|
23558
|
+
},
|
|
23559
|
+
full: {
|
|
23560
|
+
type: "boolean",
|
|
23561
|
+
description: "Run the heavy enrichment blocks that the lean default path skips: Research Signals (doc search) and npm version-drift. Default false \u2014 the lean path keeps the per-session query count down so orient stays well under the tool timeout on large projects. Implied automatically when deep_housekeeping is true."
|
|
22810
23562
|
}
|
|
22811
23563
|
},
|
|
22812
23564
|
required: []
|
|
@@ -22837,7 +23589,7 @@ function formatSynthesisParagraph(opts) {
|
|
|
22837
23589
|
parts.push(`Next: ${cleanMode}`);
|
|
22838
23590
|
return parts.join(" ");
|
|
22839
23591
|
}
|
|
22840
|
-
function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName) {
|
|
23592
|
+
function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName, teamSummary) {
|
|
22841
23593
|
const lines = [];
|
|
22842
23594
|
const cycleIsComplete = health.latestCycleStatus === "complete";
|
|
22843
23595
|
const tagSuffix = latestTag ? ` \u2014 ${latestTag}` : "";
|
|
@@ -22904,6 +23656,10 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
|
|
|
22904
23656
|
lines.push("## Board");
|
|
22905
23657
|
lines.push(health.boardSummary);
|
|
22906
23658
|
lines.push("");
|
|
23659
|
+
if (teamSummary) {
|
|
23660
|
+
lines.push(teamSummary);
|
|
23661
|
+
lines.push("");
|
|
23662
|
+
}
|
|
22907
23663
|
if (subAgents.length > 0) {
|
|
22908
23664
|
lines.push(`**Sub-agents (${subAgents.length}):** ` + subAgents.map((a) => {
|
|
22909
23665
|
const desc = a.description ? ` (${a.description.slice(0, 60)})` : "";
|
|
@@ -23108,9 +23864,56 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
|
|
|
23108
23864
|
}
|
|
23109
23865
|
return { alertsNote, unactionedIssuesNote };
|
|
23110
23866
|
}
|
|
23867
|
+
async function computeTeamSummary(adapter2) {
|
|
23868
|
+
if (typeof adapter2.listContributors !== "function") return void 0;
|
|
23869
|
+
let members;
|
|
23870
|
+
try {
|
|
23871
|
+
members = (await adapter2.listContributors()).length;
|
|
23872
|
+
} catch {
|
|
23873
|
+
return void 0;
|
|
23874
|
+
}
|
|
23875
|
+
if (members <= 1) return void 0;
|
|
23876
|
+
let tasks;
|
|
23877
|
+
try {
|
|
23878
|
+
tasks = await adapter2.queryBoard({ compact: true });
|
|
23879
|
+
} catch {
|
|
23880
|
+
return void 0;
|
|
23881
|
+
}
|
|
23882
|
+
const isOpen = (t) => t.status !== "Done" && t.status !== "Cancelled" && t.status !== "Archived";
|
|
23883
|
+
const pool = tasks.filter((t) => isOpen(t) && !t.assigneeId && t.cycle == null).length;
|
|
23884
|
+
const inFlight = tasks.filter((t) => t.status === "In Progress").length;
|
|
23885
|
+
const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
|
|
23886
|
+
return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
|
|
23887
|
+
}
|
|
23888
|
+
async function computeReleaseHistory(adapter2) {
|
|
23889
|
+
if (typeof adapter2.listContributors !== "function") return void 0;
|
|
23890
|
+
let contributors;
|
|
23891
|
+
try {
|
|
23892
|
+
contributors = await adapter2.listContributors();
|
|
23893
|
+
} catch {
|
|
23894
|
+
return void 0;
|
|
23895
|
+
}
|
|
23896
|
+
if (contributors.length <= 1) return void 0;
|
|
23897
|
+
let cycles;
|
|
23898
|
+
try {
|
|
23899
|
+
cycles = await adapter2.readCycles();
|
|
23900
|
+
} catch {
|
|
23901
|
+
return void 0;
|
|
23902
|
+
}
|
|
23903
|
+
const nameOf = new Map(
|
|
23904
|
+
contributors.map((c) => [c.userId, c.displayName || c.email || c.userId.slice(0, 8)])
|
|
23905
|
+
);
|
|
23906
|
+
const released = cycles.filter((c) => c.status === "complete").sort((a, b2) => b2.number - a.number).slice(0, 5).map((c) => {
|
|
23907
|
+
const who = c.userId ? nameOf.get(c.userId) ?? c.userId.slice(0, 8) : null;
|
|
23908
|
+
return `Cycle ${c.number}${who ? ` (${who})` : ""}`;
|
|
23909
|
+
});
|
|
23910
|
+
if (released.length === 0) return void 0;
|
|
23911
|
+
return `**Recent releases:** ${released.join(" \xB7 ")}`;
|
|
23912
|
+
}
|
|
23111
23913
|
async function handleOrient(adapter2, config2, args = {}) {
|
|
23112
23914
|
const environment = normaliseEnvironment(args.environment);
|
|
23113
23915
|
const deepHousekeeping = args.deep_housekeeping === true;
|
|
23916
|
+
const fullEnrichment = args.full === true || deepHousekeeping;
|
|
23114
23917
|
const tracker = new ProgressTracker("start");
|
|
23115
23918
|
try {
|
|
23116
23919
|
tracker.mark("propagate-phase-status");
|
|
@@ -23157,6 +23960,7 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23157
23960
|
ownerActionsOutcome,
|
|
23158
23961
|
ownerActionsNudgedOutcome,
|
|
23159
23962
|
ownerActionsBlockingOutcome,
|
|
23963
|
+
reviewChangesOutcome,
|
|
23160
23964
|
ttfvOutcome,
|
|
23161
23965
|
latestTagOutcome,
|
|
23162
23966
|
versionDriftOutcome,
|
|
@@ -23247,6 +24051,37 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23247
24051
|
return void 0;
|
|
23248
24052
|
}
|
|
23249
24053
|
}),
|
|
24054
|
+
// task-2105 (C296): review-verdict notification. When a build was bounced
|
|
24055
|
+
// back (request-changes/reject → status In Progress) and not yet resubmitted,
|
|
24056
|
+
// surface it in orient so the builder gets a signal — submitReview itself has
|
|
24057
|
+
// no notification channel. Derived from existing data (no new schema): latest
|
|
24058
|
+
// review per task + current status. Within-project only (board visibility
|
|
24059
|
+
// already scopes the caller); per-assignee targeting is a future refinement.
|
|
24060
|
+
tracked("review-changes-requested", async () => {
|
|
24061
|
+
if (!adapter2.getRecentReviews || !adapter2.getTask) return void 0;
|
|
24062
|
+
try {
|
|
24063
|
+
const reviews = await adapter2.getRecentReviews(100);
|
|
24064
|
+
const latestByTask = /* @__PURE__ */ new Map();
|
|
24065
|
+
for (const r of reviews) {
|
|
24066
|
+
if (!latestByTask.has(r.taskId)) latestByTask.set(r.taskId, { verdict: r.verdict, comments: r.comments });
|
|
24067
|
+
}
|
|
24068
|
+
const bounced = [];
|
|
24069
|
+
for (const [taskId, rev] of latestByTask) {
|
|
24070
|
+
if (rev.verdict !== "request-changes" && rev.verdict !== "reject") continue;
|
|
24071
|
+
const task = await adapter2.getTask(taskId);
|
|
24072
|
+
if (task && task.status === "In Progress") {
|
|
24073
|
+
bounced.push({ id: task.displayId ?? taskId, comment: rev.comments ?? "" });
|
|
24074
|
+
}
|
|
24075
|
+
}
|
|
24076
|
+
if (bounced.length === 0) return void 0;
|
|
24077
|
+
const list = bounced.slice(0, 5).map((b2) => b2.id).join(", ");
|
|
24078
|
+
const c = bounced[0].comment.trim();
|
|
24079
|
+
const snippet = c ? ` \u2014 ${bounced[0].id}: "${c.length > 80 ? `${c.slice(0, 80)}\u2026` : c}"` : "";
|
|
24080
|
+
return `\u26A0\uFE0F ${bounced.length} task${bounced.length === 1 ? "" : "s"} bounced back with changes requested: ${list}${snippet}. Address the feedback, then build_execute to resubmit.`;
|
|
24081
|
+
} catch {
|
|
24082
|
+
return void 0;
|
|
24083
|
+
}
|
|
24084
|
+
}),
|
|
23250
24085
|
// Time-to-first-value: setup/plan milestones (only for early projects).
|
|
23251
24086
|
// PERF: hasToolMilestone is a cheap EXISTS lookup; avoids 5000-row pull.
|
|
23252
24087
|
tracked("ttfv", async () => {
|
|
@@ -23265,11 +24100,16 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23265
24100
|
return `
|
|
23266
24101
|
**Time to first plan:** ${deltaMinutes} minutes (setup \u2192 first plan)`;
|
|
23267
24102
|
}),
|
|
23268
|
-
// Latest git tag + npm version drift (both exec calls with own timeouts)
|
|
24103
|
+
// Latest git tag + npm version drift (both exec calls with own timeouts).
|
|
24104
|
+
// task-2172: git-tag stays (the latest tag is part of the core summary);
|
|
24105
|
+
// version-drift is enrichment, gated behind `full`/deep_housekeeping.
|
|
23269
24106
|
tracked("git-tag", () => getLatestGitTag(config2.projectRoot)),
|
|
23270
|
-
tracked("npm-version-drift", () => checkNpmVersionDrift()),
|
|
23271
|
-
// Research Signals — research docs with pending actions since last strategy review
|
|
24107
|
+
tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
|
|
24108
|
+
// Research Signals — research docs with pending actions since last strategy review.
|
|
24109
|
+
// task-2172: heavy (doc search + AD cross-reference) and rarely actioned
|
|
24110
|
+
// mid-session — gated behind `full`/deep_housekeeping to keep the default lean.
|
|
23272
24111
|
tracked("research-signals", async () => {
|
|
24112
|
+
if (!fullEnrichment) return "";
|
|
23273
24113
|
if (!adapter2.searchDocs) return "";
|
|
23274
24114
|
const cycleHealth = await adapter2.getCycleHealth();
|
|
23275
24115
|
const lastReviewCycle = Math.max(0, cycleHealth.totalCycles - cycleHealth.cyclesSinceLastStrategyReview);
|
|
@@ -23370,8 +24210,9 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23370
24210
|
}),
|
|
23371
24211
|
// Session guidance — proactive nudges (doc_register, context bloat, mode switch)
|
|
23372
24212
|
tracked("session-guidance", async () => {
|
|
23373
|
-
const
|
|
23374
|
-
|
|
24213
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
24214
|
+
const signals = await buildSessionGuidance(callerKey);
|
|
24215
|
+
markOrient(callerKey);
|
|
23375
24216
|
if (signals.length === 0) return "";
|
|
23376
24217
|
const lines = ["\n\n## Session Guidance"];
|
|
23377
24218
|
for (const s of signals) lines.push(`- ${s}`);
|
|
@@ -23468,6 +24309,8 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23468
24309
|
if (ownerActionsNudgedWarning) buildResult.warnings.push(ownerActionsNudgedWarning);
|
|
23469
24310
|
const ownerActionsBlockingWarning = ownerActionsBlockingOutcome.status === "fulfilled" ? ownerActionsBlockingOutcome.value : void 0;
|
|
23470
24311
|
if (ownerActionsBlockingWarning) buildResult.warnings.push(ownerActionsBlockingWarning);
|
|
24312
|
+
const reviewChangesWarning = reviewChangesOutcome.status === "fulfilled" ? reviewChangesOutcome.value : void 0;
|
|
24313
|
+
if (reviewChangesWarning) buildResult.warnings.push(reviewChangesWarning);
|
|
23471
24314
|
try {
|
|
23472
24315
|
const verifyWarnings = await verifyProject(adapter2);
|
|
23473
24316
|
for (const w of verifyWarnings) buildResult.warnings.push(w);
|
|
@@ -23531,8 +24374,13 @@ ${section}`;
|
|
|
23531
24374
|
}
|
|
23532
24375
|
tracker.mark("format-summary");
|
|
23533
24376
|
const subAgents = await listAgents(config2.projectRoot);
|
|
23534
|
-
const
|
|
23535
|
-
|
|
24377
|
+
const [teamSummaryLine, releaseHistoryLine] = await Promise.all([
|
|
24378
|
+
tracked("team-summary", () => computeTeamSummary(adapter2))().catch(() => void 0),
|
|
24379
|
+
tracked("release-history", () => computeReleaseHistory(adapter2))().catch(() => void 0)
|
|
24380
|
+
]);
|
|
24381
|
+
const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
|
|
24382
|
+
const deepHint = deepHousekeeping ? "" : "\n\n*Tip: pass `full: true` for Research Signals + version-drift, or `deep_housekeeping: true` to also check orphaned branches, unrecorded commits, unregistered docs, and stale skill forks (implies `full`).*";
|
|
24383
|
+
return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary) + unblockNote + alertsNote + ttfvNote + reconciliationNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
|
|
23536
24384
|
} catch (err) {
|
|
23537
24385
|
const message = err instanceof Error ? err.message : String(err);
|
|
23538
24386
|
const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
|
|
@@ -23806,6 +24654,18 @@ async function assembleZoomOutContext(adapter2, cycleNumber, projectRoot) {
|
|
|
23806
24654
|
const lines = [];
|
|
23807
24655
|
for (const [adId, adEvents] of byDecision) {
|
|
23808
24656
|
lines.push(`**${adId}:** ${adEvents.length} events \u2014 ${adEvents.map((e) => `${e.eventType} (S${e.cycle})`).join(", ")}`);
|
|
24657
|
+
for (const e of adEvents) {
|
|
24658
|
+
const parts = [];
|
|
24659
|
+
if (e.evidenceRef) parts.push(`evidence: ${e.evidenceRef}`);
|
|
24660
|
+
if (e.metricDelta?.metric) {
|
|
24661
|
+
const d = e.metricDelta;
|
|
24662
|
+
const movement = d.before !== void 0 && d.after !== void 0 ? `${d.before}\u2192${d.after}` : d.delta !== void 0 ? `\u0394${d.delta}` : "";
|
|
24663
|
+
parts.push(`metric: ${d.metric}${movement ? ` (${movement})` : ""}`);
|
|
24664
|
+
}
|
|
24665
|
+
if (parts.length > 0) {
|
|
24666
|
+
lines.push(` - ${e.eventType} S${e.cycle}: ${parts.join("; ")}`);
|
|
24667
|
+
}
|
|
24668
|
+
}
|
|
23809
24669
|
}
|
|
23810
24670
|
adLifecycleText = lines.join("\n");
|
|
23811
24671
|
}
|
|
@@ -24136,8 +24996,7 @@ Check that the project IDs are correct and exist in the same Supabase instance.`
|
|
|
24136
24996
|
}
|
|
24137
24997
|
|
|
24138
24998
|
// src/tools/handoff.ts
|
|
24139
|
-
var
|
|
24140
|
-
var lastPrepareContextBytes2;
|
|
24999
|
+
var handoffPrepareCache = new PerCallerCache();
|
|
24141
25000
|
var handoffGenerateTool = {
|
|
24142
25001
|
name: "handoff_generate",
|
|
24143
25002
|
description: "Generate BUILD HANDOFFs for cycle tasks that don't have one yet. Run after `plan` (with skip_handoffs=true) or to regenerate stale handoffs. Uses the prepare/apply pattern \u2014 first call returns a prompt, second call persists results.",
|
|
@@ -24166,6 +25025,10 @@ var handoffGenerateTool = {
|
|
|
24166
25025
|
cycle_number: {
|
|
24167
25026
|
type: "number",
|
|
24168
25027
|
description: 'The cycle number returned from prepare phase (mode "apply" only).'
|
|
25028
|
+
},
|
|
25029
|
+
force: {
|
|
25030
|
+
type: "boolean",
|
|
25031
|
+
description: "Regenerate handoffs for tasks that ALREADY have one, overwriting the stored handoff (default false = only fill in missing handoffs). Use to propagate a changed Active Decision or dependency into an in-flight task. Pass on the prepare call (it is remembered through apply); combine with task_ids to target specific tasks."
|
|
24169
25032
|
}
|
|
24170
25033
|
},
|
|
24171
25034
|
required: []
|
|
@@ -24173,6 +25036,7 @@ var handoffGenerateTool = {
|
|
|
24173
25036
|
};
|
|
24174
25037
|
async function handleHandoffGenerate(adapter2, config2, args) {
|
|
24175
25038
|
const toolMode = args.mode;
|
|
25039
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
24176
25040
|
try {
|
|
24177
25041
|
if (toolMode === "apply") {
|
|
24178
25042
|
const resolved = await resolveLlmResponse(
|
|
@@ -24181,20 +25045,20 @@ async function handleHandoffGenerate(adapter2, config2, args) {
|
|
|
24181
25045
|
);
|
|
24182
25046
|
if (!resolved.ok) return errorResponse(resolved.error);
|
|
24183
25047
|
const llmResponse = resolved.llmResponse;
|
|
24184
|
-
const
|
|
25048
|
+
const prep = handoffPrepareCache.take(callerKey);
|
|
25049
|
+
const expectedCycleNumber = prep?.cycleNumber;
|
|
25050
|
+
const contextBytes = prep?.contextBytes;
|
|
25051
|
+
const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : expectedCycleNumber;
|
|
24185
25052
|
if (cycleNumber === void 0) {
|
|
24186
25053
|
return errorResponse('cycle_number is required for mode "apply". Pass the cycle number from the prepare phase.');
|
|
24187
25054
|
}
|
|
24188
|
-
const
|
|
24189
|
-
const contextBytes = lastPrepareContextBytes2;
|
|
24190
|
-
lastPrepareCycleNumber2 = void 0;
|
|
24191
|
-
lastPrepareContextBytes2 = void 0;
|
|
25055
|
+
const force = args.force === true || prep?.force === true;
|
|
24192
25056
|
if (expectedCycleNumber !== void 0 && cycleNumber !== expectedCycleNumber) {
|
|
24193
25057
|
return errorResponse(
|
|
24194
25058
|
`cycle_number mismatch: prepare phase returned cycle ${expectedCycleNumber} but apply received ${cycleNumber}.`
|
|
24195
25059
|
);
|
|
24196
25060
|
}
|
|
24197
|
-
const result = await applyHandoffs(adapter2, llmResponse, cycleNumber);
|
|
25061
|
+
const result = await applyHandoffs(adapter2, llmResponse, cycleNumber, force);
|
|
24198
25062
|
const lines = [];
|
|
24199
25063
|
lines.push(`**Handoff Generation \u2014 Cycle ${result.cycleNumber}**`);
|
|
24200
25064
|
lines.push(`${result.handoffsWritten} handoff(s) written: ${result.taskIds.join(", ")}`);
|
|
@@ -24209,9 +25073,13 @@ async function handleHandoffGenerate(adapter2, config2, args) {
|
|
|
24209
25073
|
}
|
|
24210
25074
|
{
|
|
24211
25075
|
const taskIds = Array.isArray(args.task_ids) ? args.task_ids.filter((id) => typeof id === "string") : void 0;
|
|
24212
|
-
const
|
|
24213
|
-
|
|
24214
|
-
|
|
25076
|
+
const force = args.force === true;
|
|
25077
|
+
const result = await prepareHandoffs(adapter2, config2, taskIds, force);
|
|
25078
|
+
handoffPrepareCache.set(callerKey, {
|
|
25079
|
+
cycleNumber: result.cycleNumber,
|
|
25080
|
+
contextBytes: result.contextBytes,
|
|
25081
|
+
force
|
|
25082
|
+
});
|
|
24215
25083
|
return textResponse(
|
|
24216
25084
|
`## PAPI Handoff Generation \u2014 Prepare Phase (Cycle ${result.cycleNumber})
|
|
24217
25085
|
|
|
@@ -24617,6 +25485,60 @@ async function handleDiscoveredIssueResolve(adapter2, args) {
|
|
|
24617
25485
|
|
|
24618
25486
|
// src/tools/project.ts
|
|
24619
25487
|
import path6 from "path";
|
|
25488
|
+
|
|
25489
|
+
// src/services/entitlements.ts
|
|
25490
|
+
var FREE_PROJECT_CAP = 3;
|
|
25491
|
+
var PAID_TIERS = /* @__PURE__ */ new Set(["pro", "team"]);
|
|
25492
|
+
var PRICING_URL = "https://getpapi.ai/pricing";
|
|
25493
|
+
async function resolveTier(adapter2) {
|
|
25494
|
+
if (typeof adapter2.getMeteredUsage !== "function") return null;
|
|
25495
|
+
try {
|
|
25496
|
+
const usage = await adapter2.getMeteredUsage();
|
|
25497
|
+
return usage?.tier ?? null;
|
|
25498
|
+
} catch {
|
|
25499
|
+
return null;
|
|
25500
|
+
}
|
|
25501
|
+
}
|
|
25502
|
+
function isPaidTier(tier) {
|
|
25503
|
+
return tier !== null && PAID_TIERS.has(tier);
|
|
25504
|
+
}
|
|
25505
|
+
async function enforceProjectCap(adapter2, target) {
|
|
25506
|
+
const tier = await resolveTier(adapter2);
|
|
25507
|
+
if (tier === null || isPaidTier(tier)) return null;
|
|
25508
|
+
if (typeof adapter2.listUserProjects !== "function") return null;
|
|
25509
|
+
let projects;
|
|
25510
|
+
try {
|
|
25511
|
+
projects = await adapter2.listUserProjects();
|
|
25512
|
+
} catch {
|
|
25513
|
+
return null;
|
|
25514
|
+
}
|
|
25515
|
+
const matchesExisting = projects.some(
|
|
25516
|
+
(p) => target.papiDir && p.papi_dir && p.papi_dir === target.papiDir || target.name && p.name && p.name.trim().toLowerCase() === target.name.trim().toLowerCase()
|
|
25517
|
+
);
|
|
25518
|
+
if (matchesExisting) return null;
|
|
25519
|
+
if (projects.length >= FREE_PROJECT_CAP) return projectCapMessage(projects.length);
|
|
25520
|
+
return null;
|
|
25521
|
+
}
|
|
25522
|
+
async function enforceContributorGate(adapter2) {
|
|
25523
|
+
const tier = await resolveTier(adapter2);
|
|
25524
|
+
if (tier === null) return null;
|
|
25525
|
+
if (tier === "team") return null;
|
|
25526
|
+
return contributorTeamMessage(tier);
|
|
25527
|
+
}
|
|
25528
|
+
function projectCapMessage(currentCount) {
|
|
25529
|
+
return `**You're on the Free plan (${currentCount} of ${FREE_PROJECT_CAP} projects).**
|
|
25530
|
+
|
|
25531
|
+
Free covers up to ${FREE_PROJECT_CAP} projects. To run more, upgrade to Pro for unlimited projects at a flat monthly price (no usage bills): ${PRICING_URL}
|
|
25532
|
+
|
|
25533
|
+
Your existing projects are untouched, and you can keep working in any of them.`;
|
|
25534
|
+
}
|
|
25535
|
+
function contributorTeamMessage(tier) {
|
|
25536
|
+
return `**Adding people to a project is a Team feature.**
|
|
25537
|
+
|
|
25538
|
+
You're on the ${tier} plan. Shared projects, roles, and the Quality Gate come with Team. Read-only viewer seats are free, so you only pay for who is actually building: ${PRICING_URL}`;
|
|
25539
|
+
}
|
|
25540
|
+
|
|
25541
|
+
// src/tools/project.ts
|
|
24620
25542
|
function workspacePapiDir(config2) {
|
|
24621
25543
|
if (!hasLocalWorkspace()) return void 0;
|
|
24622
25544
|
return config2.papiDir;
|
|
@@ -24649,6 +25571,8 @@ async function handleProjectCreate(adapter2, config2, args) {
|
|
|
24649
25571
|
if (papiDir) name = path6.basename(config2.projectRoot) || "My Project";
|
|
24650
25572
|
else return errorResponse("name is required \u2014 there is no local workspace to derive it from on the remote transport.");
|
|
24651
25573
|
}
|
|
25574
|
+
const capDenied = await enforceProjectCap(adapter2, { papiDir, name });
|
|
25575
|
+
if (capDenied) return errorResponse(capDenied);
|
|
24652
25576
|
const result = await adapter2.createUserProject({
|
|
24653
25577
|
name,
|
|
24654
25578
|
papiDir,
|
|
@@ -24790,6 +25714,8 @@ function requireEmail(args) {
|
|
|
24790
25714
|
async function handleContributorAdd(adapter2, config2, args) {
|
|
24791
25715
|
const denied = await denyUnlessOwner(adapter2, config2);
|
|
24792
25716
|
if (denied) return errorResponse(denied);
|
|
25717
|
+
const tierDenied = await enforceContributorGate(adapter2);
|
|
25718
|
+
if (tierDenied) return errorResponse(tierDenied);
|
|
24793
25719
|
const email = requireEmail(args);
|
|
24794
25720
|
if (!email) return errorResponse('A valid email is required. Example: contributor_add email="wes@example.com"');
|
|
24795
25721
|
try {
|
|
@@ -24843,6 +25769,155 @@ async function handleContributorList(adapter2, config2, _args) {
|
|
|
24843
25769
|
}
|
|
24844
25770
|
}
|
|
24845
25771
|
|
|
25772
|
+
// src/tools/task-claim.ts
|
|
25773
|
+
var taskClaimTool = {
|
|
25774
|
+
name: "task_claim",
|
|
25775
|
+
description: "Claim a task from the shared org Pool into your personal backlog (assignee = you). Atomic first-claim-wins \u2014 a concurrent double-claim is impossible. Cascades the DEPENDS ON chain: claiming a task also claims its not-Done prerequisites; if any prerequisite is already claimed by another member the whole claim is refused (a build unit is never split across owners). Does not call the Anthropic API.",
|
|
25776
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
25777
|
+
inputSchema: {
|
|
25778
|
+
type: "object",
|
|
25779
|
+
properties: {
|
|
25780
|
+
task_id: { type: "string", description: 'The task to claim, e.g. "task-2071".' }
|
|
25781
|
+
},
|
|
25782
|
+
required: ["task_id"]
|
|
25783
|
+
}
|
|
25784
|
+
};
|
|
25785
|
+
var taskUnclaimTool = {
|
|
25786
|
+
name: "task_unclaim",
|
|
25787
|
+
description: "Release a task you claimed back to the shared Pool (clears assignee). Claimer-only and pre-review \u2014 you cannot unclaim another member's task or one that has reached In Review/Done. Does not cascade. Does not call the Anthropic API.",
|
|
25788
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
25789
|
+
inputSchema: {
|
|
25790
|
+
type: "object",
|
|
25791
|
+
properties: {
|
|
25792
|
+
task_id: { type: "string", description: 'The task to unclaim, e.g. "task-2071".' }
|
|
25793
|
+
},
|
|
25794
|
+
required: ["task_id"]
|
|
25795
|
+
}
|
|
25796
|
+
};
|
|
25797
|
+
var DONE_STATUSES = /* @__PURE__ */ new Set(["Done", "Cancelled", "Archived"]);
|
|
25798
|
+
function requireTaskId(args) {
|
|
25799
|
+
const id = typeof args.task_id === "string" ? args.task_id.trim() : "";
|
|
25800
|
+
return id.length > 0 ? id : null;
|
|
25801
|
+
}
|
|
25802
|
+
async function resolveClaimerIdentity(adapter2, config2) {
|
|
25803
|
+
if (typeof adapter2.claimTask !== "function") {
|
|
25804
|
+
return { error: "Claiming is not available on this adapter." };
|
|
25805
|
+
}
|
|
25806
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
25807
|
+
const callerUserId = gate.callerUserId;
|
|
25808
|
+
if (!callerUserId) {
|
|
25809
|
+
const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
|
|
25810
|
+
return {
|
|
25811
|
+
error: "Claiming requires a resolvable user identity, but none was found" + note + ". Set PAPI_USER_ID to your account UUID (local) \u2014 hosted sessions derive it from your bearer token."
|
|
25812
|
+
};
|
|
25813
|
+
}
|
|
25814
|
+
if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
|
|
25815
|
+
try {
|
|
25816
|
+
const members = await adapter2.listContributors();
|
|
25817
|
+
if (!members.some((m) => m.userId === callerUserId)) {
|
|
25818
|
+
return { error: "Only the project owner or an active contributor can claim tasks on this project." };
|
|
25819
|
+
}
|
|
25820
|
+
} catch (err) {
|
|
25821
|
+
return { error: `Could not verify membership: ${err instanceof Error ? err.message : String(err)}` };
|
|
25822
|
+
}
|
|
25823
|
+
}
|
|
25824
|
+
return { callerUserId };
|
|
25825
|
+
}
|
|
25826
|
+
async function collectClaimChain(adapter2, root) {
|
|
25827
|
+
const byId = /* @__PURE__ */ new Map([[root.id, root]]);
|
|
25828
|
+
const queue = [root];
|
|
25829
|
+
while (queue.length > 0) {
|
|
25830
|
+
const t = queue.shift();
|
|
25831
|
+
if (!t.dependsOn) continue;
|
|
25832
|
+
const depIds = t.dependsOn.split(",").map((d) => d.trim()).filter(Boolean).filter((d) => !byId.has(d));
|
|
25833
|
+
if (depIds.length === 0) continue;
|
|
25834
|
+
const deps = await adapter2.getTasks(depIds);
|
|
25835
|
+
for (const dep of deps) {
|
|
25836
|
+
if (byId.has(dep.id)) continue;
|
|
25837
|
+
byId.set(dep.id, dep);
|
|
25838
|
+
if (!DONE_STATUSES.has(dep.status)) queue.push(dep);
|
|
25839
|
+
}
|
|
25840
|
+
}
|
|
25841
|
+
return [...byId.values()].filter((t) => !DONE_STATUSES.has(t.status));
|
|
25842
|
+
}
|
|
25843
|
+
async function handleTaskClaim(adapter2, config2, args) {
|
|
25844
|
+
const taskId = requireTaskId(args);
|
|
25845
|
+
if (!taskId) return errorResponse('A task_id is required. Example: task_claim task_id="task-2071"');
|
|
25846
|
+
const identity = await resolveClaimerIdentity(adapter2, config2);
|
|
25847
|
+
if ("error" in identity) return errorResponse(identity.error);
|
|
25848
|
+
const me = identity.callerUserId;
|
|
25849
|
+
const root = await adapter2.getTask(taskId);
|
|
25850
|
+
if (!root) return errorResponse(`Task "${taskId}" not found on the board.`);
|
|
25851
|
+
if (DONE_STATUSES.has(root.status)) {
|
|
25852
|
+
return errorResponse(`Task "${taskId}" is ${root.status} \u2014 nothing to claim.`);
|
|
25853
|
+
}
|
|
25854
|
+
const chain = await collectClaimChain(adapter2, root);
|
|
25855
|
+
const blockedByOther = chain.filter((t) => t.assigneeId && t.assigneeId !== me);
|
|
25856
|
+
const inOthersCycle = chain.filter((t) => !t.assigneeId && t.cycle != null);
|
|
25857
|
+
if (blockedByOther.length > 0 || inOthersCycle.length > 0) {
|
|
25858
|
+
const lines = [];
|
|
25859
|
+
for (const t of blockedByOther) lines.push(`- ${t.id} (${t.title}) \u2014 already claimed by another member`);
|
|
25860
|
+
for (const t of inOthersCycle) lines.push(`- ${t.id} (${t.title}) \u2014 already pulled into a cycle (not in the pool)`);
|
|
25861
|
+
return errorResponse(
|
|
25862
|
+
`Cannot claim "${taskId}" \u2014 it shares a DEPENDS ON build unit with task(s) you cannot take:
|
|
25863
|
+
${lines.join("\n")}
|
|
25864
|
+
|
|
25865
|
+
A build unit is never split across owners. Ask the current owner to unclaim, or pick a different task.`
|
|
25866
|
+
);
|
|
25867
|
+
}
|
|
25868
|
+
const alreadyMine = chain.filter((t) => t.assigneeId === me);
|
|
25869
|
+
const toClaim = chain.filter((t) => !t.assigneeId && t.cycle == null);
|
|
25870
|
+
const claimed = [];
|
|
25871
|
+
const raced = [];
|
|
25872
|
+
for (const t of toClaim) {
|
|
25873
|
+
const result = await adapter2.claimTask(t.id, me);
|
|
25874
|
+
if (result) claimed.push(result);
|
|
25875
|
+
else raced.push(t.id);
|
|
25876
|
+
}
|
|
25877
|
+
if (claimed.length === 0 && alreadyMine.length > 0 && raced.length === 0) {
|
|
25878
|
+
return textResponse(`Task "${taskId}" and its prerequisites are already in your backlog \u2014 nothing to do.`);
|
|
25879
|
+
}
|
|
25880
|
+
const parts = [];
|
|
25881
|
+
const claimedList = claimed.map((t) => `- ${t.id} (${t.title})`).join("\n");
|
|
25882
|
+
parts.push(
|
|
25883
|
+
`\u2705 Claimed **${claimed.length}** task(s) into your backlog (assignee = you, not yet in a cycle):
|
|
25884
|
+
${claimedList}`
|
|
25885
|
+
);
|
|
25886
|
+
if (alreadyMine.length > 0) {
|
|
25887
|
+
parts.push(`Already yours: ${alreadyMine.map((t) => t.id).join(", ")}.`);
|
|
25888
|
+
}
|
|
25889
|
+
if (raced.length > 0) {
|
|
25890
|
+
parts.push(
|
|
25891
|
+
`\u26A0\uFE0F ${raced.length} task(s) were claimed by someone else just now and were skipped: ${raced.join(", ")}. Re-run task_claim to see the current state.`
|
|
25892
|
+
);
|
|
25893
|
+
}
|
|
25894
|
+
parts.push("Next: run `plan` to draw a cycle from your backlog, or `task_unclaim` to release.");
|
|
25895
|
+
return textResponse(parts.join("\n\n"));
|
|
25896
|
+
}
|
|
25897
|
+
async function handleTaskUnclaim(adapter2, config2, args) {
|
|
25898
|
+
const taskId = requireTaskId(args);
|
|
25899
|
+
if (!taskId) return errorResponse('A task_id is required. Example: task_unclaim task_id="task-2071"');
|
|
25900
|
+
if (typeof adapter2.unclaimTask !== "function") {
|
|
25901
|
+
return errorResponse("Unclaiming is not available on this adapter.");
|
|
25902
|
+
}
|
|
25903
|
+
const identity = await resolveClaimerIdentity(adapter2, config2);
|
|
25904
|
+
if ("error" in identity) return errorResponse(identity.error);
|
|
25905
|
+
const me = identity.callerUserId;
|
|
25906
|
+
const result = await adapter2.unclaimTask(taskId, me);
|
|
25907
|
+
if (!result) {
|
|
25908
|
+
const task = await adapter2.getTask(taskId);
|
|
25909
|
+
if (!task) return errorResponse(`Task "${taskId}" not found on the board.`);
|
|
25910
|
+
if (task.assigneeId && task.assigneeId !== me) {
|
|
25911
|
+
return errorResponse(`Task "${taskId}" is claimed by another member \u2014 only the claimer can unclaim it.`);
|
|
25912
|
+
}
|
|
25913
|
+
if (task.status === "In Review" || task.status === "Done") {
|
|
25914
|
+
return errorResponse(`Task "${taskId}" is ${task.status} \u2014 it has passed the pre-review window and cannot be unclaimed.`);
|
|
25915
|
+
}
|
|
25916
|
+
return errorResponse(`Task "${taskId}" is not currently claimed by you \u2014 nothing to unclaim.`);
|
|
25917
|
+
}
|
|
25918
|
+
return textResponse(`\u2705 Released **${result.id}** (${result.title}) back to the shared Pool.`);
|
|
25919
|
+
}
|
|
25920
|
+
|
|
24846
25921
|
// src/services/harness-inventory.ts
|
|
24847
25922
|
import { readdir as readdir3, readFile as readFile8, stat as stat3 } from "fs/promises";
|
|
24848
25923
|
import { join as join16 } from "path";
|
|
@@ -24944,8 +26019,8 @@ async function syncHarnessInventory(adapter2, config2, opts) {
|
|
|
24944
26019
|
}
|
|
24945
26020
|
const fingerprint = await computeFingerprint(config2.projectRoot);
|
|
24946
26021
|
if (!opts?.force) {
|
|
24947
|
-
const
|
|
24948
|
-
if (
|
|
26022
|
+
const state = await adapter2.getHarnessState();
|
|
26023
|
+
if (state && state.fingerprint === fingerprint) return { changed: false };
|
|
24949
26024
|
}
|
|
24950
26025
|
const entries = await scanInventory(config2.projectRoot, opts?.toolDefs);
|
|
24951
26026
|
await adapter2.replaceHarnessInventory(entries);
|
|
@@ -25077,6 +26152,7 @@ If this is legitimate, reach out at https://getpapi.ai and we'll lift it \u2014
|
|
|
25077
26152
|
// src/server.ts
|
|
25078
26153
|
var DEFAULT_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_TOOL_TIMEOUT_MS ?? "30000", 10);
|
|
25079
26154
|
var LONG_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_LONG_TOOL_TIMEOUT_MS ?? "180000", 10);
|
|
26155
|
+
var WEDGE_PENDING_FRACTION = Math.min(1, Math.max(0, parseFloat(process.env.PAPI_WEDGE_PENDING_FRACTION ?? "0.6")));
|
|
25080
26156
|
var LONG_RUNNING_TOOLS = /* @__PURE__ */ new Set([
|
|
25081
26157
|
"plan",
|
|
25082
26158
|
"strategy_review",
|
|
@@ -25093,6 +26169,31 @@ function toolTimeoutMs(name) {
|
|
|
25093
26169
|
}
|
|
25094
26170
|
var wedgeRecoveryStats = { count: 0 };
|
|
25095
26171
|
var inflightWork = /* @__PURE__ */ new Map();
|
|
26172
|
+
var ToolTimeoutError = class extends Error {
|
|
26173
|
+
constructor(toolName, expectedMs, actualMs, wedged, pendingNote) {
|
|
26174
|
+
super(
|
|
26175
|
+
wedged ? `Tool '${toolName}' timed out after ${actualMs}ms (budget ${expectedMs}ms). A database operation ran the entire time without returning \u2014 the connection is likely wedged or contended (SUP-2026-012); a second PAPI session open against the same database is a common cause.${pendingNote} The MCP server is restarting; reconnect with /mcp in Claude Code (or restart your MCP host), and close any other open PAPI session, before the next call.` : `Tool '${toolName}' timed out after ${actualMs}ms (budget ${expectedMs}ms). No single operation was stuck \u2014 this is cumulative query load exceeding the budget, not a wedged pool, and the database is healthy.${pendingNote} Retry the call; \`orient\` runs a lean path by default (heavy enrichment is opt-in via \`full: true\`).`
|
|
26176
|
+
);
|
|
26177
|
+
this.toolName = toolName;
|
|
26178
|
+
this.expectedMs = expectedMs;
|
|
26179
|
+
this.actualMs = actualMs;
|
|
26180
|
+
this.wedged = wedged;
|
|
26181
|
+
this.pendingNote = pendingNote;
|
|
26182
|
+
this.name = "ToolTimeoutError";
|
|
26183
|
+
}
|
|
26184
|
+
};
|
|
26185
|
+
function classifyTimeout(start, actualMs, budgetMs) {
|
|
26186
|
+
const now = start + actualMs;
|
|
26187
|
+
let oldestPendingMs = 0;
|
|
26188
|
+
const parts = Array.from(inflightWork.entries()).map(([slot, slotStart]) => {
|
|
26189
|
+
const age = now - slotStart;
|
|
26190
|
+
if (age > oldestPendingMs) oldestPendingMs = age;
|
|
26191
|
+
return `${slot}(${age}ms)`;
|
|
26192
|
+
});
|
|
26193
|
+
const pendingNote = parts.length ? ` Pending work at timeout: ${parts.join(", ")}.` : " No pending work registered.";
|
|
26194
|
+
const wedged = oldestPendingMs >= budgetMs * WEDGE_PENDING_FRACTION;
|
|
26195
|
+
return { wedged, pendingNote };
|
|
26196
|
+
}
|
|
25096
26197
|
async function withToolTimeout(name, fn) {
|
|
25097
26198
|
const ms = toolTimeoutMs(name);
|
|
25098
26199
|
const start = Date.now();
|
|
@@ -25100,15 +26201,18 @@ async function withToolTimeout(name, fn) {
|
|
|
25100
26201
|
const timeoutPromise = new Promise((_, reject) => {
|
|
25101
26202
|
timer2 = setTimeout(() => {
|
|
25102
26203
|
const actualMs = Date.now() - start;
|
|
25103
|
-
|
|
25104
|
-
|
|
25105
|
-
|
|
25106
|
-
|
|
25107
|
-
|
|
25108
|
-
|
|
25109
|
-
|
|
25110
|
-
|
|
25111
|
-
|
|
26204
|
+
const { wedged, pendingNote } = classifyTimeout(start, actualMs, ms);
|
|
26205
|
+
if (wedged) {
|
|
26206
|
+
wedgeRecoveryStats.count += 1;
|
|
26207
|
+
console.error(
|
|
26208
|
+
`[papi] Wedge-recovery #${wedgeRecoveryStats.count}: tool '${name}' timed out with a stuck operation (expected=${ms}ms, actual=${actualMs}ms) (SUP-2026-012).${pendingNote}`
|
|
26209
|
+
);
|
|
26210
|
+
} else {
|
|
26211
|
+
console.error(
|
|
26212
|
+
`[papi] Slow timeout (DB healthy, not a wedge): tool '${name}' exceeded its ${ms}ms budget (actual=${actualMs}ms) via cumulative load.${pendingNote}`
|
|
26213
|
+
);
|
|
26214
|
+
}
|
|
26215
|
+
reject(new ToolTimeoutError(name, ms, actualMs, wedged, pendingNote));
|
|
25112
26216
|
}, ms);
|
|
25113
26217
|
timer2.unref();
|
|
25114
26218
|
});
|
|
@@ -25166,6 +26270,7 @@ var PAPI_TOOLS = [
|
|
|
25166
26270
|
releaseTool,
|
|
25167
26271
|
reviewListTool,
|
|
25168
26272
|
reviewSubmitTool,
|
|
26273
|
+
reviewClaimTool,
|
|
25169
26274
|
initTool,
|
|
25170
26275
|
orientTool,
|
|
25171
26276
|
hierarchyUpdateTool,
|
|
@@ -25187,6 +26292,8 @@ var PAPI_TOOLS = [
|
|
|
25187
26292
|
contributorAddTool,
|
|
25188
26293
|
contributorRemoveTool,
|
|
25189
26294
|
contributorListTool,
|
|
26295
|
+
taskClaimTool,
|
|
26296
|
+
taskUnclaimTool,
|
|
25190
26297
|
inventorySyncTool
|
|
25191
26298
|
];
|
|
25192
26299
|
function getToolMetadata() {
|
|
@@ -25290,7 +26397,8 @@ function createServer(adapter2, config2) {
|
|
|
25290
26397
|
}
|
|
25291
26398
|
}
|
|
25292
26399
|
const timer2 = startTimer();
|
|
25293
|
-
|
|
26400
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
26401
|
+
recordToolCall(name, callerKey);
|
|
25294
26402
|
const runHandler = async () => {
|
|
25295
26403
|
switch (name) {
|
|
25296
26404
|
case "plan":
|
|
@@ -25333,6 +26441,8 @@ function createServer(adapter2, config2) {
|
|
|
25333
26441
|
return handleReviewList(adapter2, config2);
|
|
25334
26442
|
case "review_submit":
|
|
25335
26443
|
return handleReviewSubmit(adapter2, config2, safeArgs);
|
|
26444
|
+
case "review_claim":
|
|
26445
|
+
return handleReviewClaim(adapter2, config2, safeArgs);
|
|
25336
26446
|
case "init":
|
|
25337
26447
|
return handleInit(config2, safeArgs);
|
|
25338
26448
|
case "orient":
|
|
@@ -25375,6 +26485,10 @@ function createServer(adapter2, config2) {
|
|
|
25375
26485
|
return handleContributorRemove(adapter2, config2, safeArgs);
|
|
25376
26486
|
case "contributor_list":
|
|
25377
26487
|
return handleContributorList(adapter2, config2, safeArgs);
|
|
26488
|
+
case "task_claim":
|
|
26489
|
+
return handleTaskClaim(adapter2, config2, safeArgs);
|
|
26490
|
+
case "task_unclaim":
|
|
26491
|
+
return handleTaskUnclaim(adapter2, config2, safeArgs);
|
|
25378
26492
|
case "inventory_sync":
|
|
25379
26493
|
return handleInventorySync(adapter2, config2, safeArgs, getToolMetadata());
|
|
25380
26494
|
default:
|
|
@@ -25383,7 +26497,7 @@ function createServer(adapter2, config2) {
|
|
|
25383
26497
|
};
|
|
25384
26498
|
const meterDecision = await checkMeter(adapter2, name, config2.projectId ?? "session");
|
|
25385
26499
|
if (meterDecision.blocked && meterDecision.usage) {
|
|
25386
|
-
recordToolOutcome(true);
|
|
26500
|
+
recordToolOutcome(true, callerKey);
|
|
25387
26501
|
return {
|
|
25388
26502
|
content: [{ type: "text", text: allowanceExceededMessage(name, meterDecision.usage) }]
|
|
25389
26503
|
};
|
|
@@ -25392,14 +26506,15 @@ function createServer(adapter2, config2) {
|
|
|
25392
26506
|
try {
|
|
25393
26507
|
result = await withToolTimeout(name, runHandler);
|
|
25394
26508
|
} catch (err) {
|
|
25395
|
-
|
|
25396
|
-
|
|
25397
|
-
|
|
25398
|
-
|
|
25399
|
-
|
|
25400
|
-
|
|
25401
|
-
|
|
25402
|
-
|
|
26509
|
+
if (err instanceof ToolTimeoutError) {
|
|
26510
|
+
recordToolOutcome(false, callerKey);
|
|
26511
|
+
if (err.wedged) {
|
|
26512
|
+
setImmediate(() => {
|
|
26513
|
+
console.error(`[papi] Exiting after '${name}' wedge timeout (wedge-recovery #${wedgeRecoveryStats.count}); user must reconnect with /mcp before the next call.`);
|
|
26514
|
+
process.exit(2);
|
|
26515
|
+
});
|
|
26516
|
+
}
|
|
26517
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }] };
|
|
25403
26518
|
}
|
|
25404
26519
|
throw err;
|
|
25405
26520
|
}
|
|
@@ -25425,7 +26540,7 @@ ${usageLine(decision.usage)}`;
|
|
|
25425
26540
|
delete result._contextBytes;
|
|
25426
26541
|
delete result._contextUtilisation;
|
|
25427
26542
|
const isError = result.content.some((c) => c.text.startsWith("Error:") || c.text.startsWith("\u274C"));
|
|
25428
|
-
recordToolOutcome(!isError);
|
|
26543
|
+
recordToolOutcome(!isError, callerKey);
|
|
25429
26544
|
try {
|
|
25430
26545
|
const clientName = server2.getClientVersion()?.name;
|
|
25431
26546
|
const metric = buildMetric(name, elapsed, usage, void 0, contextBytes, contextUtilisation, clientName);
|
|
@@ -25473,6 +26588,28 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
25473
26588
|
var BEARER_PREFIX = "papi_";
|
|
25474
26589
|
var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
|
|
25475
26590
|
var RESOURCE_METADATA_URL = process.env["PAPI_RESOURCE_METADATA_URL"] ?? "https://getpapi.ai/.well-known/oauth-protected-resource";
|
|
26591
|
+
var FRIENDLY_GET_HTML = `<!doctype html>
|
|
26592
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
26593
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
26594
|
+
<title>PAPI MCP server</title>
|
|
26595
|
+
<style>
|
|
26596
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|
26597
|
+
background:#faf6f0;color:#15171c;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
|
|
26598
|
+
.card{max-width:520px;padding:40px 36px;text-align:center}
|
|
26599
|
+
h1{font-size:22px;margin:0 0 12px;letter-spacing:.2px}
|
|
26600
|
+
p{margin:10px 0;color:#394048}
|
|
26601
|
+
a.btn{display:inline-block;margin-top:18px;padding:11px 20px;border-radius:10px;
|
|
26602
|
+
background:#15171c;color:#faf6f0;text-decoration:none;font-weight:600}
|
|
26603
|
+
code{background:#efe9e0;padding:2px 6px;border-radius:5px;font-size:13px}
|
|
26604
|
+
</style></head>
|
|
26605
|
+
<body><div class="card">
|
|
26606
|
+
<h1>This is the PAPI MCP server \u{1F44B}</h1>
|
|
26607
|
+
<p>You've reached it in a browser \u2014 that's expected to look empty. This endpoint
|
|
26608
|
+
is meant to be added to your AI tool as a connector, not opened directly.</p>
|
|
26609
|
+
<p>Add it in Claude, Cursor, or any MCP client as a custom connector pointing at
|
|
26610
|
+
<code>https://mcp.getpapi.ai/mcp</code>.</p>
|
|
26611
|
+
<a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
|
|
26612
|
+
</div></body></html>`;
|
|
25476
26613
|
var MAX_BODY_BYTES = 1 * 1024 * 1024;
|
|
25477
26614
|
var IP_RATE_WINDOW_MS = 6e4;
|
|
25478
26615
|
var IP_RATE_MAX = 60;
|
|
@@ -25594,6 +26731,13 @@ function startHttpTransport(opts) {
|
|
|
25594
26731
|
}
|
|
25595
26732
|
const bearer = extractBearer(req.headers.authorization);
|
|
25596
26733
|
if (!bearer) {
|
|
26734
|
+
const accept = req.headers["accept"] ?? "";
|
|
26735
|
+
if (req.method === "GET" && accept.includes("text/html")) {
|
|
26736
|
+
logEvent({ level: "info", msg: "friendly_get", ip, status: 200 });
|
|
26737
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", ...cors });
|
|
26738
|
+
res.end(FRIENDLY_GET_HTML);
|
|
26739
|
+
return;
|
|
26740
|
+
}
|
|
25597
26741
|
const hasHeader = Boolean(req.headers.authorization);
|
|
25598
26742
|
logEvent({
|
|
25599
26743
|
level: "warn",
|
|
@@ -25707,6 +26851,37 @@ function extractProjectOverride(body) {
|
|
|
25707
26851
|
const p = args.project;
|
|
25708
26852
|
return typeof p === "string" && p.trim().length > 0 ? p.trim() : void 0;
|
|
25709
26853
|
}
|
|
26854
|
+
var PROJECT_OPTIONAL_TOOLS = /* @__PURE__ */ new Set([
|
|
26855
|
+
"init",
|
|
26856
|
+
"setup",
|
|
26857
|
+
"project_list",
|
|
26858
|
+
"project_create",
|
|
26859
|
+
"project_switch"
|
|
26860
|
+
]);
|
|
26861
|
+
function extractToolName(body) {
|
|
26862
|
+
if (!body || typeof body !== "object") return void 0;
|
|
26863
|
+
const b2 = body;
|
|
26864
|
+
if (b2.method !== "tools/call") return void 0;
|
|
26865
|
+
const name = b2.params?.name;
|
|
26866
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
26867
|
+
}
|
|
26868
|
+
function sendProjectChoice(res, origin, body, projects) {
|
|
26869
|
+
if (res.headersSent) return;
|
|
26870
|
+
const id = (body && typeof body === "object" ? body.id : null) ?? null;
|
|
26871
|
+
const list = projects.map((p) => ` \u2022 ${p.slug}${p.name ? ` \u2014 ${p.name}` : ""}`).join("\n");
|
|
26872
|
+
const text = `You have ${projects.length} PAPI projects, so PAPI can't tell which one this call is for. Pick one and call again with \`project="<slug>"\` (or set the x-papi-project-id header):
|
|
26873
|
+
|
|
26874
|
+
${list}
|
|
26875
|
+
|
|
26876
|
+
Example: add \`project="${projects[0].slug}"\` to the tool arguments.`;
|
|
26877
|
+
const payload = {
|
|
26878
|
+
jsonrpc: "2.0",
|
|
26879
|
+
id,
|
|
26880
|
+
result: { content: [{ type: "text", text }], isError: true }
|
|
26881
|
+
};
|
|
26882
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
|
|
26883
|
+
res.end(JSON.stringify(payload));
|
|
26884
|
+
}
|
|
25710
26885
|
function resolveEffectiveProjectId(body, headerProjectId) {
|
|
25711
26886
|
const explicitProject = extractProjectOverride(body);
|
|
25712
26887
|
try {
|
|
@@ -25718,7 +26893,37 @@ function resolveEffectiveProjectId(body, headerProjectId) {
|
|
|
25718
26893
|
}
|
|
25719
26894
|
async function dispatchRequest(args) {
|
|
25720
26895
|
const { req, res, body, bearer, projectId, ip, baseConfig, dataEndpoint } = args;
|
|
25721
|
-
|
|
26896
|
+
let effectiveProjectId = resolveEffectiveProjectId(body, projectId);
|
|
26897
|
+
if (effectiveProjectId === void 0) {
|
|
26898
|
+
const toolName = extractToolName(body);
|
|
26899
|
+
if (toolName && !PROJECT_OPTIONAL_TOOLS.has(toolName)) {
|
|
26900
|
+
let projects = [];
|
|
26901
|
+
try {
|
|
26902
|
+
const probe = new ProxyPapiAdapter({ endpoint: dataEndpoint, apiKey: bearer });
|
|
26903
|
+
projects = await probe.listUserProjects();
|
|
26904
|
+
} catch {
|
|
26905
|
+
}
|
|
26906
|
+
if (projects.length === 1) {
|
|
26907
|
+
effectiveProjectId = projects[0].id;
|
|
26908
|
+
logEvent({
|
|
26909
|
+
level: "info",
|
|
26910
|
+
msg: "project_autobound",
|
|
26911
|
+
ip,
|
|
26912
|
+
bearer_prefix: bearerPrefix(bearer),
|
|
26913
|
+
project_id: effectiveProjectId
|
|
26914
|
+
});
|
|
26915
|
+
} else if (projects.length > 1) {
|
|
26916
|
+
logEvent({
|
|
26917
|
+
level: "info",
|
|
26918
|
+
msg: "project_choice_returned",
|
|
26919
|
+
ip,
|
|
26920
|
+
bearer_prefix: bearerPrefix(bearer)
|
|
26921
|
+
});
|
|
26922
|
+
sendProjectChoice(res, req.headers.origin, body, projects);
|
|
26923
|
+
return;
|
|
26924
|
+
}
|
|
26925
|
+
}
|
|
26926
|
+
}
|
|
25722
26927
|
const adapter2 = new ProxyPapiAdapter({
|
|
25723
26928
|
endpoint: dataEndpoint,
|
|
25724
26929
|
apiKey: bearer,
|