@papi-ai/server 0.7.34 → 0.7.36
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 +1768 -499
- package/dist/prompts.js +60 -9
- 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
|
}
|
|
@@ -4618,13 +4639,35 @@ import {
|
|
|
4618
4639
|
|
|
4619
4640
|
// src/config.ts
|
|
4620
4641
|
import path from "path";
|
|
4642
|
+
|
|
4643
|
+
// src/lib/help-text.ts
|
|
4644
|
+
var PAPI_DISCORD = "https://discord.gg/papi";
|
|
4645
|
+
var PAPI_DOCS = "https://getpapi.ai/docs/install";
|
|
4646
|
+
var HELP_FOOTER = `
|
|
4647
|
+
Need a hand?
|
|
4648
|
+
\u2022 Make sure you're in your project's root folder (where your code lives), then say "run setup".
|
|
4649
|
+
\u2022 Diagnose your setup: npx @papi-ai/server doctor
|
|
4650
|
+
\u2022 Hit a PAPI bug or have an idea? Say "report a bug to PAPI" \u2014 it goes straight to the
|
|
4651
|
+
PAPI team (not your project backlog), with diagnostics attached.
|
|
4652
|
+
\u2022 Get help: ${PAPI_DISCORD} \u2022 Docs: ${PAPI_DOCS}
|
|
4653
|
+
`;
|
|
4654
|
+
var HELP_FOOTER_MD = `
|
|
4655
|
+
|
|
4656
|
+
## Need a hand?
|
|
4657
|
+
- Make sure you're in your project's **root folder** (where your code lives), then say "run setup".
|
|
4658
|
+
- Diagnose your setup: \`npx @papi-ai/server doctor\`
|
|
4659
|
+
- Hit a PAPI bug or have an idea? Say **"report a bug to PAPI"** \u2014 it goes straight to the PAPI team (not your project backlog), with diagnostics attached.
|
|
4660
|
+
- Get help: [Discord](${PAPI_DISCORD}) \xB7 [Docs](${PAPI_DOCS})
|
|
4661
|
+
`;
|
|
4662
|
+
|
|
4663
|
+
// src/config.ts
|
|
4621
4664
|
function loadConfig() {
|
|
4622
4665
|
const projectArgIdx = process.argv.indexOf("--project");
|
|
4623
4666
|
const configuredRoot = projectArgIdx !== -1 ? process.argv[projectArgIdx + 1] : process.env.PAPI_PROJECT_DIR;
|
|
4624
4667
|
const projectRoot = configuredRoot ?? process.cwd();
|
|
4625
4668
|
if (!configuredRoot) {
|
|
4626
4669
|
process.stderr.write(
|
|
4627
|
-
|
|
4670
|
+
"\nPAPI is running, but no project is configured here.\n" + HELP_FOOTER + "\n"
|
|
4628
4671
|
);
|
|
4629
4672
|
}
|
|
4630
4673
|
const anthropicApiKey = process.env.PAPI_API_KEY ?? "";
|
|
@@ -4672,7 +4715,7 @@ If you intentionally self-host, set PAPI_SELF_HOST=1 in your environment to bypa
|
|
|
4672
4715
|
}
|
|
4673
4716
|
if (databaseUrl && !papiEndpoint && !explicitAdapter && !selfHostOptIn && !dataApiKey) {
|
|
4674
4717
|
throw new Error(
|
|
4675
|
-
'DATABASE_URL is set but PAPI_ADAPTER is not specified.\n\nIf you want to self-host PAPI against your own database, add to your .mcp.json env:\n "PAPI_ADAPTER": "pg"\n\nIf you are an external user with a PAPI account, remove DATABASE_URL from your config.\nExternal users authenticate via PAPI_DATA_API_KEY \u2014 no DATABASE_URL needed.\n
|
|
4718
|
+
'DATABASE_URL is set but PAPI_ADAPTER is not specified.\n\nIf you want to self-host PAPI against your own database, add to your .mcp.json env:\n "PAPI_ADAPTER": "pg"\n\nIf you are an external user with a PAPI account, remove DATABASE_URL from your config.\nExternal users authenticate via PAPI_DATA_API_KEY \u2014 no DATABASE_URL needed.\n' + HELP_FOOTER
|
|
4676
4719
|
);
|
|
4677
4720
|
}
|
|
4678
4721
|
let adapterType = papiEndpoint ? "pg" : databaseUrl && (explicitAdapter === "pg" || selfHostOptIn) ? "pg" : dataEndpoint ? "proxy" : explicitAdapter ? explicitAdapter : "proxy";
|
|
@@ -4691,7 +4734,8 @@ and unlocks dashboard features when you're ready.
|
|
|
4691
4734
|
After signing up, add this to your .mcp.json env config:
|
|
4692
4735
|
"PAPI_USER_ID": "your-email@example.com"
|
|
4693
4736
|
|
|
4694
|
-
Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env config
|
|
4737
|
+
Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env config.
|
|
4738
|
+
` + HELP_FOOTER
|
|
4695
4739
|
);
|
|
4696
4740
|
}
|
|
4697
4741
|
return {
|
|
@@ -5163,7 +5207,10 @@ function toCycleTask(raw) {
|
|
|
5163
5207
|
closureReason: raw.closure_reason || void 0,
|
|
5164
5208
|
buildHandoff: raw.build_handoff ? parseBuildHandoff(raw.build_handoff) ?? void 0 : void 0,
|
|
5165
5209
|
buildReport: raw.build_report || void 0,
|
|
5166
|
-
scopeClass: raw.scope_class === "brief" ? "brief" : "task"
|
|
5210
|
+
scopeClass: raw.scope_class === "brief" ? "brief" : "task",
|
|
5211
|
+
assigneeId: raw.assignee_id || void 0,
|
|
5212
|
+
claimSource: raw.claim_source === "pool" || raw.claim_source === "self_generated" ? raw.claim_source : void 0,
|
|
5213
|
+
reviewerId: raw.reviewer_id || void 0
|
|
5167
5214
|
};
|
|
5168
5215
|
}
|
|
5169
5216
|
function sanitizeDelimiters(value) {
|
|
@@ -5196,6 +5243,9 @@ function fromCycleTask(task) {
|
|
|
5196
5243
|
if (task.buildHandoff) raw.build_handoff = sanitizeDelimiters(serializeBuildHandoff(task.buildHandoff));
|
|
5197
5244
|
if (task.buildReport) raw.build_report = sanitizeDelimiters(task.buildReport);
|
|
5198
5245
|
if (task.scopeClass && task.scopeClass !== "task") raw.scope_class = task.scopeClass;
|
|
5246
|
+
if (task.assigneeId) raw.assignee_id = task.assigneeId;
|
|
5247
|
+
if (task.claimSource) raw.claim_source = task.claimSource;
|
|
5248
|
+
if (task.reviewerId) raw.reviewer_id = task.reviewerId;
|
|
5199
5249
|
return raw;
|
|
5200
5250
|
}
|
|
5201
5251
|
function mergeConflictHint(content) {
|
|
@@ -5262,6 +5312,7 @@ function filterTasks(tasks, options) {
|
|
|
5262
5312
|
if (options.reviewed !== void 0 && task.reviewed !== options.reviewed) return false;
|
|
5263
5313
|
if (options.module && task.module !== options.module) return false;
|
|
5264
5314
|
if (options.epic && task.epic !== options.epic) return false;
|
|
5315
|
+
if (options.assigneeId && task.assigneeId !== options.assigneeId) return false;
|
|
5265
5316
|
return true;
|
|
5266
5317
|
});
|
|
5267
5318
|
}
|
|
@@ -6043,6 +6094,7 @@ function toCycle(raw) {
|
|
|
6043
6094
|
taskIds: raw.task_ids ?? []
|
|
6044
6095
|
};
|
|
6045
6096
|
if (raw.end_date) cycle.endDate = raw.end_date;
|
|
6097
|
+
if (raw.user_id) cycle.userId = raw.user_id;
|
|
6046
6098
|
return cycle;
|
|
6047
6099
|
}
|
|
6048
6100
|
function fromCycle(cycle) {
|
|
@@ -6056,6 +6108,7 @@ function fromCycle(cycle) {
|
|
|
6056
6108
|
task_ids: cycle.taskIds
|
|
6057
6109
|
};
|
|
6058
6110
|
if (cycle.endDate) raw.end_date = cycle.endDate;
|
|
6111
|
+
if (cycle.userId) raw.user_id = cycle.userId;
|
|
6059
6112
|
return raw;
|
|
6060
6113
|
}
|
|
6061
6114
|
function extractYamlBlock2(content) {
|
|
@@ -6378,6 +6431,66 @@ var MdFileAdapter = class {
|
|
|
6378
6431
|
async updateTaskStatus(id, status) {
|
|
6379
6432
|
return this.updateTask(id, { status });
|
|
6380
6433
|
}
|
|
6434
|
+
/**
|
|
6435
|
+
* task-1763 (C293): atomic compare-and-swap task claim. First-claim-wins —
|
|
6436
|
+
* sets assigneeId only if the task is currently unclaimed. The markdown adapter
|
|
6437
|
+
* is single-process, so the read-check-write is trivially atomic here; the real
|
|
6438
|
+
* concurrency guarantee lives in the pg adapter's RETURNING CAS. Returns the
|
|
6439
|
+
* claimed task, or null if it was already claimed or does not exist.
|
|
6440
|
+
*
|
|
6441
|
+
* task-2071 (MU-3): the pooled-task invariant is `assigneeId == null && cycle
|
|
6442
|
+
* == null` — a task already pulled into someone's cycle is NOT in the pool and
|
|
6443
|
+
* cannot be claimed. Sets claimSource='pool' on success.
|
|
6444
|
+
*/
|
|
6445
|
+
async claimTask(taskId, assigneeId) {
|
|
6446
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
6447
|
+
const tasks = parseBoard(content);
|
|
6448
|
+
const idx = tasks.findIndex((t) => t.id === taskId);
|
|
6449
|
+
if (idx === -1) return null;
|
|
6450
|
+
if (tasks[idx].assigneeId || tasks[idx].cycle != null) return null;
|
|
6451
|
+
tasks[idx] = { ...tasks[idx], assigneeId, claimSource: "pool" };
|
|
6452
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
6453
|
+
return tasks[idx];
|
|
6454
|
+
}
|
|
6455
|
+
/**
|
|
6456
|
+
* task-2071 (C293, MU-3): claimer-only release. Clears assigneeId + claimSource
|
|
6457
|
+
* only if the task is currently assigned to `assigneeId` and has not entered
|
|
6458
|
+
* review. Returns the unclaimed task, or null if the caller is not the claimer,
|
|
6459
|
+
* the task has progressed, or it does not exist.
|
|
6460
|
+
*/
|
|
6461
|
+
async unclaimTask(taskId, assigneeId) {
|
|
6462
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
6463
|
+
const tasks = parseBoard(content);
|
|
6464
|
+
const idx = tasks.findIndex((t2) => t2.id === taskId);
|
|
6465
|
+
if (idx === -1) return null;
|
|
6466
|
+
const t = tasks[idx];
|
|
6467
|
+
if (t.assigneeId !== assigneeId) return null;
|
|
6468
|
+
if (t.status === "In Review" || t.status === "Done") return null;
|
|
6469
|
+
const next = { ...t };
|
|
6470
|
+
delete next.assigneeId;
|
|
6471
|
+
delete next.claimSource;
|
|
6472
|
+
tasks[idx] = next;
|
|
6473
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
6474
|
+
return tasks[idx];
|
|
6475
|
+
}
|
|
6476
|
+
/**
|
|
6477
|
+
* task-2072 (C293, MU-4): atomic review claim. Sets reviewerId only if the task
|
|
6478
|
+
* is In Review and not yet claimed for review (reviewerId == null). First-claim-
|
|
6479
|
+
* wins. Returns the claimed task, or null if already review-claimed / not In
|
|
6480
|
+
* Review / missing.
|
|
6481
|
+
*/
|
|
6482
|
+
async claimReview(taskId, reviewerId) {
|
|
6483
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
6484
|
+
const tasks = parseBoard(content);
|
|
6485
|
+
const idx = tasks.findIndex((t2) => t2.id === taskId);
|
|
6486
|
+
if (idx === -1) return null;
|
|
6487
|
+
const t = tasks[idx];
|
|
6488
|
+
if (t.status !== "In Review") return null;
|
|
6489
|
+
if (t.reviewerId) return null;
|
|
6490
|
+
tasks[idx] = { ...t, reviewerId };
|
|
6491
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
6492
|
+
return tasks[idx];
|
|
6493
|
+
}
|
|
6381
6494
|
async recordTransition(_taskId, _fromStatus, _toStatus, _changedBy) {
|
|
6382
6495
|
}
|
|
6383
6496
|
// --- Build Reports ---
|
|
@@ -6817,6 +6930,8 @@ ${footer}`);
|
|
|
6817
6930
|
- **source:** ${full.source}
|
|
6818
6931
|
` + (full.sourceRef ? `- **sourceRef:** ${full.sourceRef}
|
|
6819
6932
|
` : "") + (full.detail ? `- **detail:** ${full.detail}
|
|
6933
|
+
` : "") + (full.evidenceRef ? `- **evidenceRef:** ${full.evidenceRef}
|
|
6934
|
+
` : "") + (full.metricDelta ? `- **metricDelta:** ${JSON.stringify(full.metricDelta)}
|
|
6820
6935
|
` : "") + `- **createdAt:** ${full.createdAt}
|
|
6821
6936
|
|
|
6822
6937
|
---
|
|
@@ -6850,8 +6965,18 @@ ${footer}`);
|
|
|
6850
6965
|
const sourceMatch = block.match(/\*\*source:\*\*\s+(.+)/);
|
|
6851
6966
|
const sourceRefMatch = block.match(/\*\*sourceRef:\*\*\s+(.+)/);
|
|
6852
6967
|
const detailMatch = block.match(/\*\*detail:\*\*\s+(.+)/);
|
|
6968
|
+
const evidenceRefMatch = block.match(/\*\*evidenceRef:\*\*\s+(.+)/);
|
|
6969
|
+
const metricDeltaMatch = block.match(/\*\*metricDelta:\*\*\s+(.+)/);
|
|
6853
6970
|
const createdAtMatch = block.match(/\*\*createdAt:\*\*\s+(.+)/);
|
|
6854
6971
|
if (!idMatch || !sourceMatch || !createdAtMatch) continue;
|
|
6972
|
+
let metricDelta;
|
|
6973
|
+
if (metricDeltaMatch?.[1]) {
|
|
6974
|
+
try {
|
|
6975
|
+
metricDelta = JSON.parse(metricDeltaMatch[1].trim());
|
|
6976
|
+
} catch {
|
|
6977
|
+
metricDelta = null;
|
|
6978
|
+
}
|
|
6979
|
+
}
|
|
6855
6980
|
events.push({
|
|
6856
6981
|
id: idMatch[1].trim(),
|
|
6857
6982
|
decisionId: headingMatch[1],
|
|
@@ -6860,6 +6985,8 @@ ${footer}`);
|
|
|
6860
6985
|
source: sourceMatch[1].trim(),
|
|
6861
6986
|
sourceRef: sourceRefMatch?.[1]?.trim(),
|
|
6862
6987
|
detail: detailMatch?.[1]?.trim(),
|
|
6988
|
+
evidenceRef: evidenceRefMatch?.[1]?.trim(),
|
|
6989
|
+
metricDelta,
|
|
6863
6990
|
createdAt: createdAtMatch[1].trim()
|
|
6864
6991
|
});
|
|
6865
6992
|
}
|
|
@@ -7667,8 +7794,18 @@ function formatDetailedTask(t) {
|
|
|
7667
7794
|
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
7795
|
Notes: ${notes}` : ""}`;
|
|
7669
7796
|
}
|
|
7670
|
-
|
|
7671
|
-
|
|
7797
|
+
var PRIORITY_RANK = { "P0 Critical": 0, "P1 High": 1, "P2 Medium": 2, "P3 Low": 3 };
|
|
7798
|
+
function formatDeferredForPlan(deferred) {
|
|
7799
|
+
if (deferred.length === 0) return "";
|
|
7800
|
+
const sorted = [...deferred].sort(
|
|
7801
|
+
(a, b2) => (PRIORITY_RANK[a.priority] ?? 9) - (PRIORITY_RANK[b2.priority] ?? 9)
|
|
7802
|
+
);
|
|
7803
|
+
return `**Deferred (${deferred.length} parked \u2014 re-triageable, NOT active candidates):**
|
|
7804
|
+
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.
|
|
7805
|
+
` + sorted.map(formatCompactTask).join("\n");
|
|
7806
|
+
}
|
|
7807
|
+
function formatBoardForPlan(tasks, filters, currentCycle, deferredTasks = []) {
|
|
7808
|
+
if (tasks.length === 0 && deferredTasks.length === 0) return "No tasks on the board.";
|
|
7672
7809
|
let active = tasks.filter((t) => !PLAN_EXCLUDED_STATUSES.has(t.status));
|
|
7673
7810
|
const excludedCount = tasks.length - active.length;
|
|
7674
7811
|
if (filters) {
|
|
@@ -7677,13 +7814,24 @@ function formatBoardForPlan(tasks, filters, currentCycle) {
|
|
|
7677
7814
|
if (filters.epic) active = active.filter((t) => t.epic === filters.epic);
|
|
7678
7815
|
if (filters.priority) active = active.filter((t) => t.priority === filters.priority);
|
|
7679
7816
|
}
|
|
7817
|
+
let deferred = deferredTasks.filter((t) => t.status === "Deferred");
|
|
7818
|
+
if (filters) {
|
|
7819
|
+
if (filters.phase) deferred = deferred.filter((t) => t.phase === filters.phase);
|
|
7820
|
+
if (filters.module) deferred = deferred.filter((t) => t.module === filters.module);
|
|
7821
|
+
if (filters.epic) deferred = deferred.filter((t) => t.epic === filters.epic);
|
|
7822
|
+
if (filters.priority) deferred = deferred.filter((t) => t.priority === filters.priority);
|
|
7823
|
+
}
|
|
7824
|
+
const deferredSection = formatDeferredForPlan(deferred);
|
|
7680
7825
|
const userFilteredCount = tasks.length - excludedCount - active.length;
|
|
7681
7826
|
const filterParts = [];
|
|
7682
7827
|
if (excludedCount > 0) filterParts.push(`${excludedCount} completed filtered`);
|
|
7683
7828
|
if (userFilteredCount > 0) filterParts.push(`${userFilteredCount} excluded by filters`);
|
|
7684
7829
|
const filterSuffix = filterParts.length > 0 ? filterParts.join(", ") : "";
|
|
7685
7830
|
if (active.length === 0) {
|
|
7686
|
-
|
|
7831
|
+
const base = `No active tasks on the board (${filterSuffix || "all filtered"}).`;
|
|
7832
|
+
return deferredSection ? `${base}
|
|
7833
|
+
|
|
7834
|
+
${deferredSection}` : base;
|
|
7687
7835
|
}
|
|
7688
7836
|
const byCounts = (statuses) => statuses.map((s) => {
|
|
7689
7837
|
const n = active.filter((t) => t.status === s).length;
|
|
@@ -7692,9 +7840,12 @@ function formatBoardForPlan(tasks, filters, currentCycle) {
|
|
|
7692
7840
|
const summary = `Board: ${active.length} active tasks (${byCounts(["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Blocked"])})` + (filterSuffix ? ` \u2014 ${filterSuffix}` : "");
|
|
7693
7841
|
if (currentCycle === void 0) {
|
|
7694
7842
|
const formatted = active.map(formatDetailedTask).join("\n\n");
|
|
7695
|
-
|
|
7843
|
+
const body = `${summary}
|
|
7696
7844
|
|
|
7697
7845
|
${formatted}`;
|
|
7846
|
+
return deferredSection ? `${body}
|
|
7847
|
+
|
|
7848
|
+
${deferredSection}` : body;
|
|
7698
7849
|
}
|
|
7699
7850
|
const recent = [];
|
|
7700
7851
|
const stable = [];
|
|
@@ -7715,6 +7866,7 @@ ${formatted}`;
|
|
|
7715
7866
|
` + stable.map(formatCompactTask).join("\n")
|
|
7716
7867
|
);
|
|
7717
7868
|
}
|
|
7869
|
+
if (deferredSection) sections.push(deferredSection);
|
|
7718
7870
|
return sections.join("\n\n");
|
|
7719
7871
|
}
|
|
7720
7872
|
function formatCandidateTaskFullNotes(tasks) {
|
|
@@ -8166,11 +8318,19 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
|
|
|
8166
8318
|
|
|
8167
8319
|
2. **North Star** \u2014 Propose a one-sentence North Star statement, a success metric, and a key metric.
|
|
8168
8320
|
|
|
8169
|
-
3. **Initial Board** \u2014 Generate 3-5 tasks based on the project's actual tech stack and goals
|
|
8321
|
+
3. **Initial Board** \u2014 Generate 3-5 tasks based on the project's actual tech stack and goals.
|
|
8322
|
+
|
|
8323
|
+
**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:
|
|
8324
|
+
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.
|
|
8325
|
+
2. **Persistence / real data model** \u2014 only after the slice exists.
|
|
8326
|
+
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.
|
|
8327
|
+
4. **RLS / cloud hardening / multi-tenant security** \u2014 last. These are not cycle-1 work for a new project.
|
|
8328
|
+
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.
|
|
8329
|
+
|
|
8170
8330
|
- 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:
|
|
8331
|
+
- 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.
|
|
8332
|
+
- Task 2: The next slice, or the minimal persistence the core loop needs \u2014 only once the slice works.
|
|
8333
|
+
- 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
8334
|
- Do NOT assume web-app patterns (routes, pages, components) unless the brief explicitly describes a web application
|
|
8175
8335
|
- All tasks: status Backlog, priority P1-P2, reviewed true, phase "Phase 1"
|
|
8176
8336
|
|
|
@@ -8333,7 +8493,7 @@ Standard planning cycle with full board review.
|
|
|
8333
8493
|
}
|
|
8334
8494
|
parts.push(`
|
|
8335
8495
|
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
|
|
8496
|
+
- **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
8497
|
- **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
8498
|
- **Dependency chain:** If a task's \`dependsOn\` references incomplete tasks, it cannot be scheduled regardless of priority.
|
|
8339
8499
|
- **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.
|
|
@@ -8347,7 +8507,8 @@ Standard planning cycle with full board review.
|
|
|
8347
8507
|
- **P1 High** \u2014 Strategically aligned: directly advances the current horizon, phase, or Active Decision goals.
|
|
8348
8508
|
- **P2 Medium** \u2014 Valuable but not strategically urgent: quality improvements, efficiency, polish, infra.
|
|
8349
8509
|
- **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
|
|
8350
|
-
Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. Justify in 2-3 sentences.
|
|
8510
|
+
Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. **Capacity-fit (smaller effort) breaks ties between comparable-impact tasks \u2014 it does NOT outrank impact.** Do NOT let a task's smaller size be the deciding factor that repeatedly defers a higher-impact larger task; that is the exact failure mode this step guards against. Justify in 2-3 sentences.
|
|
8511
|
+
**\u26A0\uFE0F Large-task inclusion guarantee (task-2227, structural rule \u2014 not advisory):** Impact weighting alone has historically lost to capacity-fit \u2014 the planner repeatedly filled cycles with XS/S work and deferred high-impact L/XL P1s (9 P1s stalled 6-21 cycles as of C299; C297 named this the "sequencing inversion"). Enforce a HARD rule: **every cycle MUST contain at least one Large or XL strategically-aligned P1 task, UNLESS no unblocked L/XL P1 exists on the board.** Before finalising the selection, scan the candidate pool: if the selected cycle contains zero L/XL tasks AND \u22651 unblocked L/XL P1 exists in the backlog, you MUST force-include the highest-impact such task \u2014 displacing the lowest-impact small task if the budget requires it (respect the single-theme vs mixed-theme budget from the Cycle-sizing rule below rather than always adding on top of an already-full cycle). Record the outcome in a "Large-task guarantee:" line in \`cycleLogNotes\`: name the task the guarantee pulled in, OR "satisfied \u2014 cycle already contains an L/XL task", OR "no unblocked L/XL P1 on the board". Do NOT skip this because the small tasks look individually attractive \u2014 that attractiveness IS the bias.
|
|
8351
8512
|
**Blocked tasks:** Tasks with status "Blocked" MUST be skipped during task selection \u2014 they are waiting on external dependencies or gates and cannot be built. Do NOT generate BUILD HANDOFFs for blocked tasks. Do NOT recommend blocked tasks. If a blocked task's gate has been resolved (check the notes and recent build reports), emit a \`boardCorrections\` entry to move it back to Backlog. Report blocked task count in the cycle log.
|
|
8352
8513
|
**Cycle sizing:** Size the cycle based on what the selected tasks actually require \u2014 not a fixed budget. Select the highest-priority unblocked tasks, estimate each one's effort from its scope, and let the total emerge. The historical average effort from Methodology Trends is a reference point for calibration, not a target or floor. A healthy cycle has 6-10 tasks. Cycles with fewer than 5 tasks require explicit justification in the cycle log \u2014 explain why more tasks could not be included. When the backlog has 10+ tasks, the cycle SHOULD have 6+ tasks \u2014 undersized cycles waste planning overhead relative to the available work. If fewer than 5 tasks qualify after filtering (blocked, deferred, raw), check Deferred tasks \u2014 some may be ready to un-defer via a \`boardCorrections\` entry. A 1-task cycle is almost never correct. Prefer grouping tasks by module or similarity \u2014 reduces context switching and enables shared branches during the build phase.
|
|
8353
8514
|
**Theme-driven sizing:** Single-theme cycles (all tasks in the same module or epic) can absorb 25-30 effort points because builders maintain context across tasks. Mixed-theme cycles should stay at 15-20 effort points to limit context switching. Use the theme to determine the budget, not a fixed number.
|
|
@@ -8407,7 +8568,7 @@ ${AD_REJECTION_RULES}
|
|
|
8407
8568
|
- **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
8569
|
- **Strategy gaps:** If an Active Decision has no board tasks supporting it, propose one.
|
|
8409
8570
|
- **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. **
|
|
8571
|
+
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
8572
|
**\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
8573
|
**\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
8574
|
parts.push(PLAN_FRAGMENT_PRODUCT_BRIEF);
|
|
@@ -8485,6 +8646,9 @@ function buildPlanUserMessage(ctx) {
|
|
|
8485
8646
|
""
|
|
8486
8647
|
);
|
|
8487
8648
|
}
|
|
8649
|
+
if (ctx.siblingRepoWarning) {
|
|
8650
|
+
parts.push("", ctx.siblingRepoWarning, "");
|
|
8651
|
+
}
|
|
8488
8652
|
parts.push("", "---", "", "## PROJECT CONTEXT", "");
|
|
8489
8653
|
if (ctx.contextTier) {
|
|
8490
8654
|
parts.push(`**Context tier:** ${ctx.contextTier}`, "");
|
|
@@ -8840,7 +9004,9 @@ After your natural language output, include this EXACT format on its own line:
|
|
|
8840
9004
|
{
|
|
8841
9005
|
"id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
|
|
8842
9006
|
"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)"
|
|
9007
|
+
"body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
|
|
9008
|
+
"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.",
|
|
9009
|
+
"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
9010
|
}
|
|
8845
9011
|
],
|
|
8846
9012
|
"decisionScores": [
|
|
@@ -8900,6 +9066,8 @@ The JSON must be valid. Use null for optional fields that don't apply.
|
|
|
8900
9066
|
For activeDecisionUpdates, the body field must be the COMPLETE replacement text for the AD block (including the ### heading line).
|
|
8901
9067
|
Only include ADs that need changes \u2014 omit unchanged ADs.${compressionNote}
|
|
8902
9068
|
|
|
9069
|
+
**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.
|
|
9070
|
+
|
|
8903
9071
|
## PERSISTENCE RULES \u2014 READ THIS CAREFULLY
|
|
8904
9072
|
|
|
8905
9073
|
Everything in Part 1 (natural language) is **display-only**. Part 2 (structured JSON) is what gets written to files.
|
|
@@ -9065,7 +9233,9 @@ After your natural language output, include this EXACT format on its own line:
|
|
|
9065
9233
|
{
|
|
9066
9234
|
"id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
|
|
9067
9235
|
"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)"
|
|
9236
|
+
"body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
|
|
9237
|
+
"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.",
|
|
9238
|
+
"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
9239
|
}
|
|
9070
9240
|
],
|
|
9071
9241
|
"phaseUpdates": [
|
|
@@ -9382,6 +9552,37 @@ Return a JSON array of 3-10 tasks. Each task must have:
|
|
|
9382
9552
|
- Use the full complexity range: XS (config/one-liner), Small (one file), Medium (2-5 files), Large (cross-module), XL (architectural)
|
|
9383
9553
|
- Tasks should be specific enough to execute without further investigation
|
|
9384
9554
|
- Maximum 10 tasks \u2014 fewer is better if the codebase is well-maintained`;
|
|
9555
|
+
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.
|
|
9556
|
+
|
|
9557
|
+
IMPORTANT: You are running as a non-interactive API call. Do NOT ask questions. Produce tasks directly.
|
|
9558
|
+
|
|
9559
|
+
## OUTPUT FORMAT
|
|
9560
|
+
|
|
9561
|
+
Return a JSON array of 15-20 tasks. Each task must have:
|
|
9562
|
+
- "title": Clear, actionable task title (start with a verb)
|
|
9563
|
+
- "priority": "P0 Critical", "P1 High", "P2 Medium", or "P3 Low"
|
|
9564
|
+
- "complexity": "XS", "Small", "Medium", "Large", or "XL"
|
|
9565
|
+
- "module": A module name inferred from the vision (e.g. "Core", "Auth", "Frontend", "API", "Payments")
|
|
9566
|
+
- "phase": A phase name ("Phase 1" for the first shippable slice, "Phase 2" for what follows, etc.)
|
|
9567
|
+
- "notes": 1-2 sentences tying this task to the user's stated vision
|
|
9568
|
+
|
|
9569
|
+
## GUIDELINES
|
|
9570
|
+
|
|
9571
|
+
- Cover the VISION, not a generic app skeleton. Every task must trace to something the user actually described.
|
|
9572
|
+
- 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.
|
|
9573
|
+
- Mix: the first shippable feature slice (Phase 1), then the next features, then supporting/foundational work behind them.
|
|
9574
|
+
- 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.
|
|
9575
|
+
- Use the full complexity range. Keep titles concrete enough to execute without re-deriving the vision.
|
|
9576
|
+
- Do NOT add PAPI-setup tasks \u2014 those are handled by the setup flow.`;
|
|
9577
|
+
function buildVisionTasksPrompt(inputs) {
|
|
9578
|
+
const line = (label, val) => val?.trim() ? `**${label}:** ${val.trim()}
|
|
9579
|
+
` : "";
|
|
9580
|
+
return `Turn this project vision into a 15-20 item starter backlog.
|
|
9581
|
+
|
|
9582
|
+
**Project:** ${inputs.projectName}
|
|
9583
|
+
${line("What it is", inputs.description)}${line("Target users", inputs.targetUsers)}${line("Problems it solves", inputs.problems)}${line("Project type", inputs.projectType)}
|
|
9584
|
+
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.`;
|
|
9585
|
+
}
|
|
9385
9586
|
function buildInitialTasksPrompt(inputs) {
|
|
9386
9587
|
const description = inputs.description?.trim() ? `**Description:** ${inputs.description}
|
|
9387
9588
|
` : `**Description:** (not provided \u2014 derive from the codebase analysis below)
|
|
@@ -9533,8 +9734,8 @@ function oneLine(ad) {
|
|
|
9533
9734
|
}
|
|
9534
9735
|
function formatHierarchy(horizons, stages) {
|
|
9535
9736
|
if (!horizons.length && !stages.length) return "";
|
|
9536
|
-
const activeHorizon = horizons.find((h) => h.status === "
|
|
9537
|
-
const activeStage = stages.find((s) => s.status === "
|
|
9737
|
+
const activeHorizon = horizons.find((h) => h.status === "In Progress") ?? horizons[0];
|
|
9738
|
+
const activeStage = stages.find((s) => s.status === "In Progress") ?? stages[0];
|
|
9538
9739
|
const parts = [];
|
|
9539
9740
|
if (activeHorizon) parts.push(activeHorizon.slug || activeHorizon.label);
|
|
9540
9741
|
if (activeStage) parts.push(activeStage.slug || activeStage.label);
|
|
@@ -9557,7 +9758,7 @@ function validateHandoffScope(handoff) {
|
|
|
9557
9758
|
if (!isMeaningful(handoff.scopeBoundary)) invalid.push("scopeBoundary");
|
|
9558
9759
|
return invalid;
|
|
9559
9760
|
}
|
|
9560
|
-
async function prepareHandoffs(adapter2, _config, taskIds) {
|
|
9761
|
+
async function prepareHandoffs(adapter2, _config, taskIds, force = false) {
|
|
9561
9762
|
const timer2 = startTimer();
|
|
9562
9763
|
const cycles = await adapter2.readCycles();
|
|
9563
9764
|
const activeCycle = cycles.find((c) => c.status === "active");
|
|
@@ -9571,10 +9772,10 @@ async function prepareHandoffs(adapter2, _config, taskIds) {
|
|
|
9571
9772
|
const idSet = new Set(taskIds);
|
|
9572
9773
|
cycleTasks = cycleTasks.filter((t) => idSet.has(t.id));
|
|
9573
9774
|
}
|
|
9574
|
-
const tasksNeedingHandoffs = cycleTasks.filter((t) => !t.buildHandoff);
|
|
9775
|
+
const tasksNeedingHandoffs = force ? cycleTasks : cycleTasks.filter((t) => !t.buildHandoff);
|
|
9575
9776
|
if (tasksNeedingHandoffs.length === 0) {
|
|
9576
9777
|
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.`
|
|
9778
|
+
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
9779
|
);
|
|
9579
9780
|
}
|
|
9580
9781
|
const [decisions, reports, brief] = await Promise.all([
|
|
@@ -9605,7 +9806,7 @@ async function prepareHandoffs(adapter2, _config, taskIds) {
|
|
|
9605
9806
|
taskCount: tasksNeedingHandoffs.length
|
|
9606
9807
|
};
|
|
9607
9808
|
}
|
|
9608
|
-
async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber) {
|
|
9809
|
+
async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false) {
|
|
9609
9810
|
const timer2 = startTimer();
|
|
9610
9811
|
const { data } = parseStructuredOutput(rawLlmOutput);
|
|
9611
9812
|
if (!data) {
|
|
@@ -9629,7 +9830,7 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber) {
|
|
|
9629
9830
|
const warnings = [];
|
|
9630
9831
|
for (const handoff of handoffs) {
|
|
9631
9832
|
try {
|
|
9632
|
-
if (existingHandoffSet.has(handoff.taskId)) {
|
|
9833
|
+
if (!force && existingHandoffSet.has(handoff.taskId)) {
|
|
9633
9834
|
console.error(`[handoff] skipping ${handoff.taskId} \u2014 already has handoff`);
|
|
9634
9835
|
skipped++;
|
|
9635
9836
|
continue;
|
|
@@ -10005,7 +10206,72 @@ async function resolveVisibilityFromDocs(adapter2, docRefs) {
|
|
|
10005
10206
|
};
|
|
10006
10207
|
}
|
|
10007
10208
|
|
|
10209
|
+
// src/lib/owner-identity.ts
|
|
10210
|
+
function isProjectOwner(callerUserId, ownerUserId) {
|
|
10211
|
+
if (!callerUserId || !ownerUserId) return false;
|
|
10212
|
+
const caller = callerUserId.trim().toLowerCase();
|
|
10213
|
+
const owner = ownerUserId.trim().toLowerCase();
|
|
10214
|
+
if (caller.length === 0 || owner.length === 0) return false;
|
|
10215
|
+
return caller === owner;
|
|
10216
|
+
}
|
|
10217
|
+
async function resolveOwnerGate(adapter2, config2) {
|
|
10218
|
+
if (typeof adapter2.getOwnerIdentity === "function") {
|
|
10219
|
+
try {
|
|
10220
|
+
const identity = await adapter2.getOwnerIdentity();
|
|
10221
|
+
const callerUserId = identity.callerUserId ?? config2.userId ?? null;
|
|
10222
|
+
return {
|
|
10223
|
+
enforced: true,
|
|
10224
|
+
callerIsOwner: isProjectOwner(callerUserId, identity.ownerUserId),
|
|
10225
|
+
callerUserId,
|
|
10226
|
+
ownerUserId: identity.ownerUserId
|
|
10227
|
+
};
|
|
10228
|
+
} catch (err) {
|
|
10229
|
+
return {
|
|
10230
|
+
enforced: true,
|
|
10231
|
+
callerIsOwner: false,
|
|
10232
|
+
callerUserId: config2.userId ?? null,
|
|
10233
|
+
ownerUserId: null,
|
|
10234
|
+
resolutionError: err instanceof Error ? err.message : String(err)
|
|
10235
|
+
};
|
|
10236
|
+
}
|
|
10237
|
+
}
|
|
10238
|
+
if (typeof adapter2.getProjectOwnerUserId === "function") {
|
|
10239
|
+
let ownerUserId = null;
|
|
10240
|
+
let resolutionError;
|
|
10241
|
+
try {
|
|
10242
|
+
ownerUserId = await adapter2.getProjectOwnerUserId();
|
|
10243
|
+
} catch (err) {
|
|
10244
|
+
resolutionError = err instanceof Error ? err.message : String(err);
|
|
10245
|
+
}
|
|
10246
|
+
const callerUserId = config2.userId ?? null;
|
|
10247
|
+
return {
|
|
10248
|
+
enforced: true,
|
|
10249
|
+
callerIsOwner: isProjectOwner(callerUserId, ownerUserId),
|
|
10250
|
+
callerUserId,
|
|
10251
|
+
ownerUserId,
|
|
10252
|
+
...resolutionError !== void 0 ? { resolutionError } : {}
|
|
10253
|
+
};
|
|
10254
|
+
}
|
|
10255
|
+
return {
|
|
10256
|
+
enforced: false,
|
|
10257
|
+
callerIsOwner: true,
|
|
10258
|
+
callerUserId: config2.userId ?? null,
|
|
10259
|
+
ownerUserId: null
|
|
10260
|
+
};
|
|
10261
|
+
}
|
|
10262
|
+
|
|
10008
10263
|
// src/services/plan.ts
|
|
10264
|
+
async function resolvePlanScope(adapter2, config2) {
|
|
10265
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
10266
|
+
return { callerUserId: gate.callerUserId, ownerUserId: gate.ownerUserId, callerIsOwner: gate.callerIsOwner };
|
|
10267
|
+
}
|
|
10268
|
+
function filterToPersonalBacklog(tasks, scope) {
|
|
10269
|
+
if (!scope.callerUserId) return tasks;
|
|
10270
|
+
if (scope.callerIsOwner) {
|
|
10271
|
+
return tasks.filter((t) => !t.assigneeId || t.assigneeId === scope.callerUserId);
|
|
10272
|
+
}
|
|
10273
|
+
return tasks.filter((t) => t.assigneeId === scope.callerUserId);
|
|
10274
|
+
}
|
|
10009
10275
|
function determineContextTier(cycleCount) {
|
|
10010
10276
|
if (cycleCount <= 5) return 1;
|
|
10011
10277
|
if (cycleCount <= 20) return 2;
|
|
@@ -10294,8 +10560,8 @@ var BRIEF_SECTIONS = [
|
|
|
10294
10560
|
}
|
|
10295
10561
|
];
|
|
10296
10562
|
function assessBriefThinness(brief) {
|
|
10297
|
-
const
|
|
10298
|
-
const briefWithoutTemplate = brief.replace(
|
|
10563
|
+
const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
|
|
10564
|
+
const briefWithoutTemplate = brief.replace(TEMPLATE_MARKER2, "");
|
|
10299
10565
|
const populated = [];
|
|
10300
10566
|
const missing = [];
|
|
10301
10567
|
for (const section of BRIEF_SECTIONS) {
|
|
@@ -10434,12 +10700,34 @@ async function readDogfoodEntries(projectRoot, count = 5, adapter2) {
|
|
|
10434
10700
|
return void 0;
|
|
10435
10701
|
}
|
|
10436
10702
|
}
|
|
10703
|
+
function formatSiblingRepoWarning(siblings) {
|
|
10704
|
+
const lines = siblings.slice(0, 20).map(
|
|
10705
|
+
(s) => `- ${s.displayId} [${s.sourceProjectName}] (${s.status}${s.epic ? `, ${s.epic}` : ""}): ${s.title}`
|
|
10706
|
+
);
|
|
10707
|
+
const more = siblings.length > 20 ? `
|
|
10708
|
+
\u2026and ${siblings.length - 20} more` : "";
|
|
10709
|
+
return [
|
|
10710
|
+
"## \u26A0\uFE0F SHARED-REPO SIBLING WORK \u2014 CHECK BEFORE SCHEDULING",
|
|
10711
|
+
"",
|
|
10712
|
+
"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.)",
|
|
10713
|
+
"",
|
|
10714
|
+
...lines,
|
|
10715
|
+
more
|
|
10716
|
+
].filter(Boolean).join("\n");
|
|
10717
|
+
}
|
|
10437
10718
|
async function assembleContext(adapter2, mode, _config, filters, focus) {
|
|
10438
10719
|
const totalTimer = startTimer();
|
|
10439
10720
|
const timings = {};
|
|
10721
|
+
const planScope = await resolvePlanScope(adapter2, _config);
|
|
10440
10722
|
let t = startTimer();
|
|
10441
10723
|
const health = await adapter2.getCycleHealth();
|
|
10442
10724
|
timings["getCycleHealth"] = t();
|
|
10725
|
+
let siblingRepoWarning;
|
|
10726
|
+
try {
|
|
10727
|
+
const siblings = await adapter2.getSiblingRepoTasks?.() ?? [];
|
|
10728
|
+
if (siblings.length > 0) siblingRepoWarning = formatSiblingRepoWarning(siblings);
|
|
10729
|
+
} catch {
|
|
10730
|
+
}
|
|
10443
10731
|
t = startTimer();
|
|
10444
10732
|
const [rawProductBrief, currentNorthStar] = await Promise.all([
|
|
10445
10733
|
adapter2.readProductBrief(),
|
|
@@ -10564,9 +10852,10 @@ ${lines.join("\n")}`;
|
|
|
10564
10852
|
const doneIds = doneTasksResult.status === "fulfilled" ? new Set(doneTasksResult.value.map((t2) => t2.displayId)) : void 0;
|
|
10565
10853
|
carryForwardStalenessLean = computeCarryForwardStaleness(cycleLogResult.value, doneIds);
|
|
10566
10854
|
}
|
|
10855
|
+
const candidateTasksLean = preAssignedResult.status === "fulfilled" ? filterToPersonalBacklog(preAssignedResult.value, planScope) : [];
|
|
10567
10856
|
let preAssignedTextLean;
|
|
10568
10857
|
if (preAssignedResult.status === "fulfilled") {
|
|
10569
|
-
const preAssigned2 =
|
|
10858
|
+
const preAssigned2 = candidateTasksLean.filter((t2) => t2.cycle === leanTargetCycle);
|
|
10570
10859
|
preAssignedTextLean = formatPreAssignedTasks(preAssigned2, leanTargetCycle);
|
|
10571
10860
|
}
|
|
10572
10861
|
let recentlyShippedLean;
|
|
@@ -10575,7 +10864,7 @@ ${lines.join("\n")}`;
|
|
|
10575
10864
|
}
|
|
10576
10865
|
let candidateTaskFullNotesLean;
|
|
10577
10866
|
if (preAssignedResult.status === "fulfilled") {
|
|
10578
|
-
candidateTaskFullNotesLean = formatCandidateTaskFullNotes(
|
|
10867
|
+
candidateTaskFullNotesLean = formatCandidateTaskFullNotes(candidateTasksLean);
|
|
10579
10868
|
}
|
|
10580
10869
|
logDataSourceSummary("plan (lean)", [
|
|
10581
10870
|
{ label: "cycleHealth", hasData: !!health },
|
|
@@ -10617,7 +10906,8 @@ ${lines.join("\n")}`;
|
|
|
10617
10906
|
recentlyShippedCapabilities: recentlyShippedLean,
|
|
10618
10907
|
strategyReviewCadence,
|
|
10619
10908
|
candidateTaskFullNotes: candidateTaskFullNotesLean,
|
|
10620
|
-
foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief)
|
|
10909
|
+
foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief),
|
|
10910
|
+
siblingRepoWarning
|
|
10621
10911
|
};
|
|
10622
10912
|
const { label: leanTierLabel } = applyContextTier(ctx2, health.totalCycles);
|
|
10623
10913
|
ctx2.contextTier = leanTierLabel;
|
|
@@ -10633,7 +10923,7 @@ ${lines.join("\n")}`;
|
|
|
10633
10923
|
return { context: ctx2, contextHashes: newHashes2 };
|
|
10634
10924
|
}
|
|
10635
10925
|
t = startTimer();
|
|
10636
|
-
const [decisions, reportsSinceCycle, log2, tasks, rawMetricsSnapshots, reviews, phases, dogfoodEntries, allBuildReports] = await Promise.all([
|
|
10926
|
+
const [decisions, reportsSinceCycle, log2, tasks, rawMetricsSnapshots, reviews, phases, dogfoodEntries, allBuildReports, deferredRaw] = await Promise.all([
|
|
10637
10927
|
adapter2.getActiveDecisions(),
|
|
10638
10928
|
adapter2.getBuildReportsSince(health.totalCycles ?? 0),
|
|
10639
10929
|
adapter2.getCycleLog(3),
|
|
@@ -10642,7 +10932,10 @@ ${lines.join("\n")}`;
|
|
|
10642
10932
|
adapter2.getRecentReviews(5),
|
|
10643
10933
|
adapter2.readPhases(),
|
|
10644
10934
|
readDogfoodEntries(_config.projectRoot, 5, adapter2),
|
|
10645
|
-
adapter2.getRecentBuildReports(50)
|
|
10935
|
+
adapter2.getRecentBuildReports(50),
|
|
10936
|
+
// task-2198: Deferred tasks queried SEPARATELY so they reach the planner as a
|
|
10937
|
+
// re-triageable set without ever entering the selectable candidate pool.
|
|
10938
|
+
adapter2.queryBoard({ status: ["Deferred"], contextTier: 2, compact: true })
|
|
10646
10939
|
]);
|
|
10647
10940
|
timings["fullQueries"] = t();
|
|
10648
10941
|
const reports = reportsSinceCycle.length > 0 ? reportsSinceCycle : allBuildReports.slice(0, 5);
|
|
@@ -10699,9 +10992,13 @@ ${lines.join("\n")}`;
|
|
|
10699
10992
|
const briefExcluded = strippedTasks.filter(
|
|
10700
10993
|
(t2) => t2.scopeClass === "brief" && !ACTIVE_STATUSES2.has(t2.status)
|
|
10701
10994
|
);
|
|
10702
|
-
const plannerTasks =
|
|
10703
|
-
|
|
10995
|
+
const plannerTasks = filterToPersonalBacklog(
|
|
10996
|
+
strippedTasks.filter(
|
|
10997
|
+
(t2) => (t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)) && (t2.scopeClass !== "brief" || ACTIVE_STATUSES2.has(t2.status))
|
|
10998
|
+
),
|
|
10999
|
+
planScope
|
|
10704
11000
|
);
|
|
11001
|
+
const deferredTasks = filterToPersonalBacklog(stripTasksForPlan(deferredRaw), planScope);
|
|
10705
11002
|
if (p3Excluded.length > 0) {
|
|
10706
11003
|
console.error(`[plan-perf] board tiering: excluded ${p3Excluded.length} P3 Low tasks from planner context`);
|
|
10707
11004
|
}
|
|
@@ -10771,7 +11068,7 @@ ${logLines}`);
|
|
|
10771
11068
|
activeDecisions: formatActiveDecisionsForPlan(decisions),
|
|
10772
11069
|
recentBuildReports: formatBuildReports(cappedReports),
|
|
10773
11070
|
cycleLog: formatCycleLog(log2),
|
|
10774
|
-
board: formatBoardForPlan(plannerTasks, filters, health.totalCycles),
|
|
11071
|
+
board: formatBoardForPlan(plannerTasks, filters, health.totalCycles, deferredTasks),
|
|
10775
11072
|
northStar,
|
|
10776
11073
|
methodologyMetrics: formatCycleMetrics(metricsSnapshots),
|
|
10777
11074
|
recentReviews: formatReviews(reviews),
|
|
@@ -10794,7 +11091,8 @@ ${logLines}`);
|
|
|
10794
11091
|
recentlyShippedCapabilities: formatRecentlyShippedCapabilities(reports),
|
|
10795
11092
|
strategyReviewCadence: strategyReviewCadenceFull,
|
|
10796
11093
|
candidateTaskFullNotes: formatCandidateTaskFullNotes(plannerTasks),
|
|
10797
|
-
foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief)
|
|
11094
|
+
foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief),
|
|
11095
|
+
siblingRepoWarning
|
|
10798
11096
|
};
|
|
10799
11097
|
const { label: fullTierLabel } = applyContextTier(ctx, health.totalCycles);
|
|
10800
11098
|
ctx.contextTier = fullTierLabel;
|
|
@@ -10860,7 +11158,10 @@ ${cleanContent}`;
|
|
|
10860
11158
|
goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
|
|
10861
11159
|
boardHealth: data.boardHealth || "",
|
|
10862
11160
|
taskIds: cycleTaskIds,
|
|
10863
|
-
contextHashes
|
|
11161
|
+
contextHashes,
|
|
11162
|
+
// task-2071 (MU-3): own the cycle by the planning member (pg defaults to the
|
|
11163
|
+
// project owner when unset, so owner-operated plans stay correct).
|
|
11164
|
+
...options.ownerUserId ? { userId: options.ownerUserId } : {}
|
|
10864
11165
|
};
|
|
10865
11166
|
let dedupedNewTasks = data.newTasks ?? [];
|
|
10866
11167
|
if (dedupedNewTasks.length > 0) {
|
|
@@ -10945,25 +11246,12 @@ ${cleanContent}`;
|
|
|
10945
11246
|
const writeBackMs = writeBackTimer();
|
|
10946
11247
|
console.error(`[plan-perf] transactionalWriteBack: total=${writeBackMs}ms`);
|
|
10947
11248
|
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");
|
|
11249
|
+
const expectedNewTasks = dedupedNewTasks.length;
|
|
11250
|
+
const actualNewTasks = result.newTaskIdMap.size;
|
|
11251
|
+
if (expectedNewTasks > 0 && actualNewTasks < expectedNewTasks) {
|
|
11252
|
+
verifyWarnings.push(
|
|
11253
|
+
`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.)`
|
|
11254
|
+
);
|
|
10967
11255
|
}
|
|
10968
11256
|
const allWarnings = [...result.warnings, ...verifyWarnings];
|
|
10969
11257
|
const handoffCount = data.cycleHandoffs?.length ?? 0;
|
|
@@ -11258,7 +11546,9 @@ ${cleanContent}`;
|
|
|
11258
11546
|
goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
|
|
11259
11547
|
boardHealth: data.boardHealth || "",
|
|
11260
11548
|
taskIds: cycleTaskIds,
|
|
11261
|
-
contextHashes
|
|
11549
|
+
contextHashes,
|
|
11550
|
+
// task-2071 (MU-3): own the cycle by the planning member.
|
|
11551
|
+
...options.ownerUserId ? { userId: options.ownerUserId } : {}
|
|
11262
11552
|
};
|
|
11263
11553
|
await adapter2.createCycle(cycle);
|
|
11264
11554
|
} catch (err) {
|
|
@@ -11300,7 +11590,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
|
|
|
11300
11590
|
for (const c of cycles) {
|
|
11301
11591
|
if (!newestByNumber.has(c.number)) newestByNumber.set(c.number, c);
|
|
11302
11592
|
}
|
|
11303
|
-
const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber);
|
|
11593
|
+
const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber).filter((c) => opts.userId == null || c.userId === opts.userId);
|
|
11304
11594
|
if (blocking.length === 0) return [];
|
|
11305
11595
|
if (!opts.autoComplete) {
|
|
11306
11596
|
const nums = blocking.map((c) => c.number).join(", ");
|
|
@@ -11319,7 +11609,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
|
|
|
11319
11609
|
}
|
|
11320
11610
|
return notes;
|
|
11321
11611
|
}
|
|
11322
|
-
async function validateAndPrepare(adapter2, force) {
|
|
11612
|
+
async function validateAndPrepare(adapter2, force, callerUserId) {
|
|
11323
11613
|
let mode;
|
|
11324
11614
|
let cycleNumber;
|
|
11325
11615
|
let strategyReviewWarning = "";
|
|
@@ -11358,7 +11648,7 @@ Run \`review_submit\` to clear them, or pass \`force: true\` to bypass this bloc
|
|
|
11358
11648
|
);
|
|
11359
11649
|
}
|
|
11360
11650
|
}
|
|
11361
|
-
const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true });
|
|
11651
|
+
const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true, userId: callerUserId ?? void 0 });
|
|
11362
11652
|
for (const note of autoCompleted) {
|
|
11363
11653
|
console.error(`[plan] ${note}`);
|
|
11364
11654
|
}
|
|
@@ -11390,7 +11680,8 @@ Run \`strategy_review\` first, or pass \`force: true\` to bypass this gate.`
|
|
|
11390
11680
|
}
|
|
11391
11681
|
async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber, contextHashes, planRunMeta) {
|
|
11392
11682
|
const applyStartMs = Date.now();
|
|
11393
|
-
await
|
|
11683
|
+
const applyScope = await resolvePlanScope(adapter2, config2);
|
|
11684
|
+
await assertSingleActiveCycle(adapter2, { allowNumber: cycleNumber + 1, userId: applyScope.callerUserId ?? void 0 });
|
|
11394
11685
|
const { displayText, data } = parseStructuredOutput(rawOutput);
|
|
11395
11686
|
let resolvedDisplayText = displayText;
|
|
11396
11687
|
let autoCommitNote = "";
|
|
@@ -11409,7 +11700,7 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
|
|
|
11409
11700
|
cycleNumber,
|
|
11410
11701
|
data,
|
|
11411
11702
|
contextHashes,
|
|
11412
|
-
{ confirmCancellations: planRunMeta?.confirmCancellations === true }
|
|
11703
|
+
{ confirmCancellations: planRunMeta?.confirmCancellations === true, ownerUserId: applyScope.callerUserId ?? void 0 }
|
|
11413
11704
|
);
|
|
11414
11705
|
if (wbWarnings.length > 0) {
|
|
11415
11706
|
writeBackWarnings = wbWarnings;
|
|
@@ -11484,7 +11775,8 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
|
|
|
11484
11775
|
const prepareTimer = startTimer();
|
|
11485
11776
|
tracker?.mark("validate_and_prepare");
|
|
11486
11777
|
let t = startTimer();
|
|
11487
|
-
const
|
|
11778
|
+
const prepareScope = await resolvePlanScope(adapter2, config2);
|
|
11779
|
+
const { mode, cycleNumber, strategyReviewWarning } = await validateAndPrepare(adapter2, force, prepareScope.callerUserId);
|
|
11488
11780
|
const validateMs = t();
|
|
11489
11781
|
if (handoffsOnly) {
|
|
11490
11782
|
tracker?.mark("handoffs_only_assemble");
|
|
@@ -11530,8 +11822,8 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
|
|
|
11530
11822
|
t = startTimer();
|
|
11531
11823
|
const { context, contextHashes } = await assembleContext(adapter2, mode, config2, filters, focus);
|
|
11532
11824
|
const assembleMs = t();
|
|
11533
|
-
const
|
|
11534
|
-
if (mode !== "bootstrap" && context.productBrief.includes(
|
|
11825
|
+
const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
|
|
11826
|
+
if (mode !== "bootstrap" && context.productBrief.includes(TEMPLATE_MARKER2)) {
|
|
11535
11827
|
throw new Error("TEMPLATE_BRIEF");
|
|
11536
11828
|
}
|
|
11537
11829
|
if (skipHandoffs) context.skipHandoffs = true;
|
|
@@ -11748,6 +12040,7 @@ function defaultHint(tool) {
|
|
|
11748
12040
|
"plan",
|
|
11749
12041
|
"strategy_review",
|
|
11750
12042
|
"orient",
|
|
12043
|
+
"papi",
|
|
11751
12044
|
"build_execute",
|
|
11752
12045
|
"review_list",
|
|
11753
12046
|
"review_submit",
|
|
@@ -11755,9 +12048,9 @@ function defaultHint(tool) {
|
|
|
11755
12048
|
"release"
|
|
11756
12049
|
]);
|
|
11757
12050
|
if (knownTools.has(tool)) {
|
|
11758
|
-
return `Re-run \`${tool}\`. If the crash repeats,
|
|
12051
|
+
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
12052
|
}
|
|
11760
|
-
return "Re-run the tool. If the crash repeats,
|
|
12053
|
+
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
12054
|
}
|
|
11762
12055
|
|
|
11763
12056
|
// src/lib/subagent-dispatch.ts
|
|
@@ -11899,12 +12192,174 @@ async function resolveLlmResponse(inlineResponse, filePath) {
|
|
|
11899
12192
|
return { ok: true, llmResponse: contents };
|
|
11900
12193
|
}
|
|
11901
12194
|
|
|
12195
|
+
// src/services/session-guidance.ts
|
|
12196
|
+
var DEFAULT_CALLER_KEY = "__default__";
|
|
12197
|
+
var sessionStates = /* @__PURE__ */ new Map();
|
|
12198
|
+
var MAX_SESSION_STATES = 1e3;
|
|
12199
|
+
function newSessionState(now) {
|
|
12200
|
+
return {
|
|
12201
|
+
toolCallCount: 0,
|
|
12202
|
+
lastOrientAt: null,
|
|
12203
|
+
releaseSinceLastOrient: false,
|
|
12204
|
+
sessionStartedAt: now,
|
|
12205
|
+
lastReviewListAt: null,
|
|
12206
|
+
failureTimestamps: [],
|
|
12207
|
+
consecutiveFailures: 0,
|
|
12208
|
+
lastTouchedAt: now
|
|
12209
|
+
};
|
|
12210
|
+
}
|
|
12211
|
+
function evictIfNeeded() {
|
|
12212
|
+
if (sessionStates.size <= MAX_SESSION_STATES) return;
|
|
12213
|
+
const entries = [...sessionStates.entries()].sort(
|
|
12214
|
+
(a, b2) => a[1].lastTouchedAt - b2[1].lastTouchedAt
|
|
12215
|
+
);
|
|
12216
|
+
const overflow = sessionStates.size - MAX_SESSION_STATES;
|
|
12217
|
+
for (let i = 0; i < overflow; i++) {
|
|
12218
|
+
sessionStates.delete(entries[i][0]);
|
|
12219
|
+
}
|
|
12220
|
+
}
|
|
12221
|
+
function getState(callerKey) {
|
|
12222
|
+
const key = callerKey ?? DEFAULT_CALLER_KEY;
|
|
12223
|
+
const now = Date.now();
|
|
12224
|
+
let s = sessionStates.get(key);
|
|
12225
|
+
if (!s) {
|
|
12226
|
+
s = newSessionState(now);
|
|
12227
|
+
sessionStates.set(key, s);
|
|
12228
|
+
evictIfNeeded();
|
|
12229
|
+
} else {
|
|
12230
|
+
s.lastTouchedAt = now;
|
|
12231
|
+
}
|
|
12232
|
+
return s;
|
|
12233
|
+
}
|
|
12234
|
+
function callerKeyFromConfig(config2) {
|
|
12235
|
+
return config2.projectId ?? config2.userId ?? void 0;
|
|
12236
|
+
}
|
|
12237
|
+
var CONTEXT_BLOAT_CALL_THRESHOLD = 40;
|
|
12238
|
+
var ORIENT_GAP_MS = 3 * 60 * 60 * 1e3;
|
|
12239
|
+
var REVIEW_LIST_GUARD_WINDOW_MS = 15 * 60 * 1e3;
|
|
12240
|
+
var FAILURE_WINDOW_MS = 30 * 60 * 1e3;
|
|
12241
|
+
var FAILURE_RATE_THRESHOLD = 8;
|
|
12242
|
+
var CONSECUTIVE_FAILURE_THRESHOLD = 4;
|
|
12243
|
+
function recordToolCall(name, callerKey) {
|
|
12244
|
+
const state = getState(callerKey);
|
|
12245
|
+
state.toolCallCount++;
|
|
12246
|
+
if (name === "release") state.releaseSinceLastOrient = true;
|
|
12247
|
+
if (name === "review_list") state.lastReviewListAt = Date.now();
|
|
12248
|
+
}
|
|
12249
|
+
function recordToolOutcome(success, callerKey) {
|
|
12250
|
+
const state = getState(callerKey);
|
|
12251
|
+
if (success) {
|
|
12252
|
+
state.consecutiveFailures = 0;
|
|
12253
|
+
return;
|
|
12254
|
+
}
|
|
12255
|
+
const now = Date.now();
|
|
12256
|
+
state.consecutiveFailures++;
|
|
12257
|
+
state.failureTimestamps.push(now);
|
|
12258
|
+
const cutoff = now - FAILURE_WINDOW_MS;
|
|
12259
|
+
state.failureTimestamps = state.failureTimestamps.filter((t) => t >= cutoff);
|
|
12260
|
+
}
|
|
12261
|
+
function wasReviewListSeenRecently(windowMs = REVIEW_LIST_GUARD_WINDOW_MS, callerKey) {
|
|
12262
|
+
const state = getState(callerKey);
|
|
12263
|
+
if (state.lastReviewListAt == null) return false;
|
|
12264
|
+
return Date.now() - state.lastReviewListAt <= windowMs;
|
|
12265
|
+
}
|
|
12266
|
+
function markOrient(callerKey) {
|
|
12267
|
+
const state = getState(callerKey);
|
|
12268
|
+
state.lastOrientAt = Date.now();
|
|
12269
|
+
state.releaseSinceLastOrient = false;
|
|
12270
|
+
}
|
|
12271
|
+
function getProjectConnectionBanner(projectName, projectSlug) {
|
|
12272
|
+
if (!projectName || !projectSlug) return null;
|
|
12273
|
+
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.`;
|
|
12274
|
+
}
|
|
12275
|
+
function detectContextDegradation(now = Date.now(), callerKey) {
|
|
12276
|
+
const state = getState(callerKey);
|
|
12277
|
+
if (state.consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
|
12278
|
+
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.`;
|
|
12279
|
+
}
|
|
12280
|
+
const cutoff = now - FAILURE_WINDOW_MS;
|
|
12281
|
+
const recentFailures = state.failureTimestamps.filter((t) => t >= cutoff).length;
|
|
12282
|
+
if (recentFailures >= FAILURE_RATE_THRESHOLD) {
|
|
12283
|
+
const mins = Math.round(FAILURE_WINDOW_MS / 6e4);
|
|
12284
|
+
return `Context degradation (distraction/confusion): ${recentFailures} tool failures in the last ${mins}min. A fresh window often clears this faster than pushing on.`;
|
|
12285
|
+
}
|
|
12286
|
+
return null;
|
|
12287
|
+
}
|
|
12288
|
+
async function buildSessionGuidance(callerKey) {
|
|
12289
|
+
const state = getState(callerKey);
|
|
12290
|
+
const signals = [];
|
|
12291
|
+
const degradation = detectContextDegradation(Date.now(), callerKey);
|
|
12292
|
+
if (degradation) signals.push(degradation);
|
|
12293
|
+
if (state.toolCallCount > CONTEXT_BLOAT_CALL_THRESHOLD) {
|
|
12294
|
+
signals.push(
|
|
12295
|
+
`${state.toolCallCount} tool calls this session \u2014 context may be bloated. Consider starting a fresh window.`
|
|
12296
|
+
);
|
|
12297
|
+
}
|
|
12298
|
+
if (state.lastOrientAt && Date.now() - state.lastOrientAt > ORIENT_GAP_MS) {
|
|
12299
|
+
const hours = Math.round((Date.now() - state.lastOrientAt) / (60 * 60 * 1e3));
|
|
12300
|
+
signals.push(
|
|
12301
|
+
`${hours}h since last orient \u2014 session may be stale. Consider a fresh window for best results.`
|
|
12302
|
+
);
|
|
12303
|
+
}
|
|
12304
|
+
if (state.releaseSinceLastOrient) {
|
|
12305
|
+
signals.push(
|
|
12306
|
+
"Release just ran \u2014 start a fresh session before the next `plan` to keep planning context clean."
|
|
12307
|
+
);
|
|
12308
|
+
}
|
|
12309
|
+
return signals.slice(0, 3);
|
|
12310
|
+
}
|
|
12311
|
+
|
|
12312
|
+
// src/lib/per-caller-cache.ts
|
|
12313
|
+
var DEFAULT_CALLER_KEY2 = "__default__";
|
|
12314
|
+
var MAX_PER_CALLER_ENTRIES = 1e3;
|
|
12315
|
+
var PerCallerCache = class {
|
|
12316
|
+
constructor(max = MAX_PER_CALLER_ENTRIES) {
|
|
12317
|
+
this.max = max;
|
|
12318
|
+
}
|
|
12319
|
+
store = /* @__PURE__ */ new Map();
|
|
12320
|
+
/** Stash the prepare-phase value for this caller (undefined caller → default bucket). */
|
|
12321
|
+
set(callerKey, value) {
|
|
12322
|
+
this.store.set(callerKey ?? DEFAULT_CALLER_KEY2, { value, touchedAt: this.now() });
|
|
12323
|
+
this.evictIfNeeded();
|
|
12324
|
+
}
|
|
12325
|
+
/**
|
|
12326
|
+
* Read the caller's prepare-phase value WITHOUT removing it — used to peek the
|
|
12327
|
+
* expected value during apply-validation (the prior singletons were read then
|
|
12328
|
+
* cleared, so callers that need the clear-on-read semantics use `take`).
|
|
12329
|
+
*/
|
|
12330
|
+
peek(callerKey) {
|
|
12331
|
+
return this.store.get(callerKey ?? DEFAULT_CALLER_KEY2)?.value;
|
|
12332
|
+
}
|
|
12333
|
+
/** Read AND remove this caller's value — the prepare→apply handshake consumes it once. */
|
|
12334
|
+
take(callerKey) {
|
|
12335
|
+
const key = callerKey ?? DEFAULT_CALLER_KEY2;
|
|
12336
|
+
const entry = this.store.get(key);
|
|
12337
|
+
if (entry) this.store.delete(key);
|
|
12338
|
+
return entry?.value;
|
|
12339
|
+
}
|
|
12340
|
+
/** Drop this caller's value without reading it. */
|
|
12341
|
+
clear(callerKey) {
|
|
12342
|
+
this.store.delete(callerKey ?? DEFAULT_CALLER_KEY2);
|
|
12343
|
+
}
|
|
12344
|
+
/** Test/inspection helper — number of live caller buckets. */
|
|
12345
|
+
size() {
|
|
12346
|
+
return this.store.size;
|
|
12347
|
+
}
|
|
12348
|
+
now() {
|
|
12349
|
+
return Date.now();
|
|
12350
|
+
}
|
|
12351
|
+
evictIfNeeded() {
|
|
12352
|
+
if (this.store.size <= this.max) return;
|
|
12353
|
+
const entries = [...this.store.entries()].sort((a, b2) => a[1].touchedAt - b2[1].touchedAt);
|
|
12354
|
+
const overflow = this.store.size - this.max;
|
|
12355
|
+
for (let i = 0; i < overflow; i++) {
|
|
12356
|
+
this.store.delete(entries[i][0]);
|
|
12357
|
+
}
|
|
12358
|
+
}
|
|
12359
|
+
};
|
|
12360
|
+
|
|
11902
12361
|
// src/tools/plan.ts
|
|
11903
|
-
var
|
|
11904
|
-
var lastPrepareUserMessage;
|
|
11905
|
-
var lastPrepareContextBytes;
|
|
11906
|
-
var lastPrepareCycleNumber;
|
|
11907
|
-
var lastPrepareSkipHandoffs;
|
|
12362
|
+
var planPrepareCache = new PerCallerCache();
|
|
11908
12363
|
var planTool = {
|
|
11909
12364
|
name: "plan",
|
|
11910
12365
|
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 +12501,7 @@ function formatPlanResult(result) {
|
|
|
12046
12501
|
}
|
|
12047
12502
|
async function handlePlan(adapter2, config2, args) {
|
|
12048
12503
|
const toolMode = args.mode;
|
|
12504
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
12049
12505
|
const filters = {};
|
|
12050
12506
|
if (typeof args.phase === "string") filters.phase = args.phase;
|
|
12051
12507
|
if (typeof args.module === "string") filters.module = args.module;
|
|
@@ -12068,11 +12524,12 @@ async function handlePlan(adapter2, config2, args) {
|
|
|
12068
12524
|
const planMode = args.plan_mode || "full";
|
|
12069
12525
|
const rawCycleNumber = args.cycle_number != null ? Number(args.cycle_number) : NaN;
|
|
12070
12526
|
const strategyReviewWarning = args.strategy_review_warning || "";
|
|
12071
|
-
const
|
|
12072
|
-
const
|
|
12073
|
-
const
|
|
12074
|
-
|
|
12075
|
-
|
|
12527
|
+
const prep = planPrepareCache.peek(callerKey);
|
|
12528
|
+
const contextHashes = prep?.contextHashes;
|
|
12529
|
+
const inputContext = prep?.userMessage;
|
|
12530
|
+
const contextBytes = prep?.contextBytes;
|
|
12531
|
+
let expectedCycleNumber = prep?.cycleNumber;
|
|
12532
|
+
const skipHandoffsCached = prep?.skipHandoffs;
|
|
12076
12533
|
const skipHandoffs = args.skip_handoffs === true || skipHandoffsCached === true;
|
|
12077
12534
|
if (expectedCycleNumber === void 0 && process.env.PAPI_GUARD_RESTART === "true") {
|
|
12078
12535
|
try {
|
|
@@ -12094,11 +12551,7 @@ async function handlePlan(adapter2, config2, args) {
|
|
|
12094
12551
|
);
|
|
12095
12552
|
}
|
|
12096
12553
|
const cycleNumber = newCycleNumber - 1;
|
|
12097
|
-
|
|
12098
|
-
lastPrepareUserMessage = void 0;
|
|
12099
|
-
lastPrepareContextBytes = void 0;
|
|
12100
|
-
lastPrepareCycleNumber = void 0;
|
|
12101
|
-
lastPrepareSkipHandoffs = void 0;
|
|
12554
|
+
planPrepareCache.clear(callerKey);
|
|
12102
12555
|
let utilisation;
|
|
12103
12556
|
if (inputContext) {
|
|
12104
12557
|
try {
|
|
@@ -12127,11 +12580,13 @@ async function handlePlan(adapter2, config2, args) {
|
|
|
12127
12580
|
}
|
|
12128
12581
|
const skipHandoffs = args.skip_handoffs === true;
|
|
12129
12582
|
const result = await preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker);
|
|
12130
|
-
|
|
12131
|
-
|
|
12132
|
-
|
|
12133
|
-
|
|
12134
|
-
|
|
12583
|
+
planPrepareCache.set(callerKey, {
|
|
12584
|
+
contextHashes: result.contextHashes,
|
|
12585
|
+
userMessage: result.userMessage,
|
|
12586
|
+
contextBytes: result.contextBytes,
|
|
12587
|
+
cycleNumber: result.cycleNumber,
|
|
12588
|
+
skipHandoffs: skipHandoffs || void 0
|
|
12589
|
+
});
|
|
12135
12590
|
const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
|
|
12136
12591
|
const autoDispatchThreshold = 50 * 1024;
|
|
12137
12592
|
let dispatch;
|
|
@@ -13406,7 +13861,18 @@ ${lines.join("\n")}`;
|
|
|
13406
13861
|
console.error(`[strategy_review] Context size: ${contextSize.toLocaleString()} chars (~${estimatedTokens.toLocaleString()} tokens)`);
|
|
13407
13862
|
return context;
|
|
13408
13863
|
}
|
|
13409
|
-
|
|
13864
|
+
function extractDecisionEvidence(ad, eventType, warnings) {
|
|
13865
|
+
const isDeliberate = eventType === "validated" || eventType === "invalidated" || eventType === "modified";
|
|
13866
|
+
const evidenceRef = ad.evidenceRef?.trim() || void 0;
|
|
13867
|
+
const metricDelta = ad.metricDelta ?? null;
|
|
13868
|
+
if (isDeliberate && !evidenceRef && !metricDelta?.metric) {
|
|
13869
|
+
warnings?.push(
|
|
13870
|
+
`${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.`
|
|
13871
|
+
);
|
|
13872
|
+
}
|
|
13873
|
+
return { evidenceRef, metricDelta };
|
|
13874
|
+
}
|
|
13875
|
+
async function writeBack2(adapter2, cycleNumber, data, fullAnalysis, warnings) {
|
|
13410
13876
|
const cleanTitle = data.sessionLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
|
|
13411
13877
|
const cleanContent = data.sessionLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
|
|
13412
13878
|
const lastReviewCycle = await adapter2.getLastStrategyReviewCycle();
|
|
@@ -13460,6 +13926,7 @@ ${cleanContent}`;
|
|
|
13460
13926
|
await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
|
|
13461
13927
|
}
|
|
13462
13928
|
const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
|
|
13929
|
+
const evidence = extractDecisionEvidence(ad, eventType, warnings);
|
|
13463
13930
|
try {
|
|
13464
13931
|
await adapter2.appendDecisionEvent({
|
|
13465
13932
|
decisionId: ad.id,
|
|
@@ -13467,7 +13934,9 @@ ${cleanContent}`;
|
|
|
13467
13934
|
cycle: cycleNumber,
|
|
13468
13935
|
source: "strategy_review",
|
|
13469
13936
|
sourceRef: `cycle-${cycleNumber}-review`,
|
|
13470
|
-
detail: `Action: ${ad.action}
|
|
13937
|
+
detail: `Action: ${ad.action}`,
|
|
13938
|
+
evidenceRef: evidence.evidenceRef,
|
|
13939
|
+
metricDelta: evidence.metricDelta
|
|
13471
13940
|
});
|
|
13472
13941
|
} catch {
|
|
13473
13942
|
}
|
|
@@ -13626,6 +14095,7 @@ async function processReviewOutput(adapter2, rawOutput, cycleNumber) {
|
|
|
13626
14095
|
let slackWarning;
|
|
13627
14096
|
let writeBackFailed;
|
|
13628
14097
|
let phaseChanges;
|
|
14098
|
+
const evidenceWarnings = [];
|
|
13629
14099
|
if (!data) {
|
|
13630
14100
|
const marker = "<!-- PAPI_STRUCTURED_OUTPUT -->";
|
|
13631
14101
|
const hasMarker = rawOutput.includes(marker);
|
|
@@ -13639,7 +14109,7 @@ async function processReviewOutput(adapter2, rawOutput, cycleNumber) {
|
|
|
13639
14109
|
}
|
|
13640
14110
|
if (data) {
|
|
13641
14111
|
try {
|
|
13642
|
-
phaseChanges = await writeBack2(adapter2, cycleNumber, data, displayText);
|
|
14112
|
+
phaseChanges = await writeBack2(adapter2, cycleNumber, data, displayText, evidenceWarnings);
|
|
13643
14113
|
} catch (err) {
|
|
13644
14114
|
writeBackFailed = err instanceof Error ? err.message : String(err);
|
|
13645
14115
|
try {
|
|
@@ -13687,9 +14157,15 @@ ${report}`;
|
|
|
13687
14157
|
**Discovery Canvas Auto-Populated:** ${populated.join(", ")}`;
|
|
13688
14158
|
}
|
|
13689
14159
|
}
|
|
14160
|
+
const evidenceWarningSection = evidenceWarnings.length ? `
|
|
14161
|
+
|
|
14162
|
+
---
|
|
14163
|
+
|
|
14164
|
+
**Decision-ledger evidence warnings (${evidenceWarnings.length}):**
|
|
14165
|
+
${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : "";
|
|
13690
14166
|
const fullText = phaseChanges?.length ? `${displayText}
|
|
13691
14167
|
|
|
13692
|
-
${formatPhaseChanges(phaseChanges)}${valueReportSection}${canvasSection}` : `${displayText}${valueReportSection}${canvasSection}`;
|
|
14168
|
+
${formatPhaseChanges(phaseChanges)}${valueReportSection}${canvasSection}${evidenceWarningSection}` : `${displayText}${valueReportSection}${canvasSection}${evidenceWarningSection}`;
|
|
13693
14169
|
return {
|
|
13694
14170
|
cycleNumber,
|
|
13695
14171
|
displayText: fullText,
|
|
@@ -13996,6 +14472,7 @@ function buildStrategyChangeUserMessage(cycleNumber, text, productBrief, activeD
|
|
|
13996
14472
|
async function processStrategyChangeOutput(adapter2, rawOutput, cycleNumber) {
|
|
13997
14473
|
const { displayText, data } = parseStrategyChangeOutput(rawOutput);
|
|
13998
14474
|
let writeBackFailed;
|
|
14475
|
+
const evidenceWarnings = [];
|
|
13999
14476
|
if (data) {
|
|
14000
14477
|
try {
|
|
14001
14478
|
const cleanTitle = data.cycleLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
|
|
@@ -14023,6 +14500,7 @@ ${cleanContent}`;
|
|
|
14023
14500
|
await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
|
|
14024
14501
|
}
|
|
14025
14502
|
const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
|
|
14503
|
+
const evidence = extractDecisionEvidence(ad, eventType, evidenceWarnings);
|
|
14026
14504
|
try {
|
|
14027
14505
|
await adapter2.appendDecisionEvent({
|
|
14028
14506
|
decisionId: ad.id,
|
|
@@ -14030,7 +14508,9 @@ ${cleanContent}`;
|
|
|
14030
14508
|
cycle: cycleNumber,
|
|
14031
14509
|
source: "strategy_change",
|
|
14032
14510
|
sourceRef: `cycle-${cycleNumber}-change`,
|
|
14033
|
-
detail: `Action: ${ad.action}
|
|
14511
|
+
detail: `Action: ${ad.action}`,
|
|
14512
|
+
evidenceRef: evidence.evidenceRef,
|
|
14513
|
+
metricDelta: evidence.metricDelta
|
|
14034
14514
|
});
|
|
14035
14515
|
} catch {
|
|
14036
14516
|
}
|
|
@@ -14050,7 +14530,13 @@ ${cleanContent}`;
|
|
|
14050
14530
|
writeBackFailed = err instanceof Error ? err.message : String(err);
|
|
14051
14531
|
}
|
|
14052
14532
|
}
|
|
14053
|
-
|
|
14533
|
+
const fullText = evidenceWarnings.length ? `${displayText}
|
|
14534
|
+
|
|
14535
|
+
---
|
|
14536
|
+
|
|
14537
|
+
**Decision-ledger evidence warnings (${evidenceWarnings.length}):**
|
|
14538
|
+
${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : displayText;
|
|
14539
|
+
return { cycleNumber, displayText: fullText, writeBackFailed };
|
|
14054
14540
|
}
|
|
14055
14541
|
async function prepareStrategyChange(adapter2, text) {
|
|
14056
14542
|
let cycleNumber;
|
|
@@ -14189,11 +14675,10 @@ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change c
|
|
|
14189
14675
|
}
|
|
14190
14676
|
|
|
14191
14677
|
// src/tools/strategy.ts
|
|
14192
|
-
var
|
|
14193
|
-
var lastReviewContextBytes;
|
|
14678
|
+
var reviewPrepareCache = new PerCallerCache();
|
|
14194
14679
|
var strategyReviewTool = {
|
|
14195
14680
|
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.
|
|
14681
|
+
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
14682
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
14198
14683
|
inputSchema: {
|
|
14199
14684
|
type: "object",
|
|
@@ -14258,7 +14743,7 @@ var strategyAgendaTool = {
|
|
|
14258
14743
|
};
|
|
14259
14744
|
var strategyChangeTool = {
|
|
14260
14745
|
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.',
|
|
14746
|
+
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
14747
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
14263
14748
|
inputSchema: {
|
|
14264
14749
|
type: "object",
|
|
@@ -14296,6 +14781,10 @@ var strategyChangeTool = {
|
|
|
14296
14781
|
confidence_only: {
|
|
14297
14782
|
type: "boolean",
|
|
14298
14783
|
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.`
|
|
14784
|
+
},
|
|
14785
|
+
north_star: {
|
|
14786
|
+
type: "string",
|
|
14787
|
+
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
14788
|
}
|
|
14300
14789
|
},
|
|
14301
14790
|
required: []
|
|
@@ -14304,6 +14793,7 @@ var strategyChangeTool = {
|
|
|
14304
14793
|
async function handleStrategyReview(adapter2, config2, args) {
|
|
14305
14794
|
const toolMode = args.mode;
|
|
14306
14795
|
const force = args.force === true;
|
|
14796
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
14307
14797
|
const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate");
|
|
14308
14798
|
try {
|
|
14309
14799
|
if (toolMode === "apply") {
|
|
@@ -14316,10 +14806,9 @@ async function handleStrategyReview(adapter2, config2, args) {
|
|
|
14316
14806
|
}
|
|
14317
14807
|
const llmResponse = resolved.llmResponse;
|
|
14318
14808
|
const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : 0;
|
|
14319
|
-
const
|
|
14320
|
-
const
|
|
14321
|
-
|
|
14322
|
-
lastReviewContextBytes = void 0;
|
|
14809
|
+
const prep = reviewPrepareCache.take(callerKey);
|
|
14810
|
+
const inputContext = prep?.userMessage;
|
|
14811
|
+
const contextBytes = prep?.contextBytes;
|
|
14323
14812
|
const result = await applyStrategyReviewOutput(adapter2, llmResponse, cycleNumber, tracker);
|
|
14324
14813
|
let utilisation;
|
|
14325
14814
|
if (inputContext) {
|
|
@@ -14384,14 +14873,17 @@ ${recLines.join("\n")}
|
|
|
14384
14873
|
if ("notDue" in result) {
|
|
14385
14874
|
return textResponse(result.message);
|
|
14386
14875
|
}
|
|
14387
|
-
|
|
14388
|
-
|
|
14876
|
+
const reviewContextBytes = Buffer.byteLength(result.userMessage, "utf-8");
|
|
14877
|
+
reviewPrepareCache.set(callerKey, {
|
|
14878
|
+
userMessage: result.userMessage,
|
|
14879
|
+
contextBytes: reviewContextBytes
|
|
14880
|
+
});
|
|
14389
14881
|
const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
|
|
14390
14882
|
const autoDispatchThreshold = 50 * 1024;
|
|
14391
14883
|
let dispatch;
|
|
14392
14884
|
if (args.dispatch === "inline" || args.dispatch === "subagent") {
|
|
14393
14885
|
dispatch = args.dispatch;
|
|
14394
|
-
} else if (autoDispatchEnabled &&
|
|
14886
|
+
} else if (autoDispatchEnabled && reviewContextBytes > autoDispatchThreshold) {
|
|
14395
14887
|
dispatch = "subagent";
|
|
14396
14888
|
} else {
|
|
14397
14889
|
dispatch = "inline";
|
|
@@ -14402,7 +14894,7 @@ ${recLines.join("\n")}
|
|
|
14402
14894
|
cycleNumber: result.cycleNumber,
|
|
14403
14895
|
systemPrompt: result.systemPrompt,
|
|
14404
14896
|
userMessage: result.userMessage,
|
|
14405
|
-
contextBytes:
|
|
14897
|
+
contextBytes: reviewContextBytes
|
|
14406
14898
|
});
|
|
14407
14899
|
return textResponse(dispatchPrompt);
|
|
14408
14900
|
}
|
|
@@ -14499,9 +14991,25 @@ async function handleStrategyChange(adapter2, _config, args) {
|
|
|
14499
14991
|
const toolMode = args.mode;
|
|
14500
14992
|
try {
|
|
14501
14993
|
if (toolMode === "capture") {
|
|
14994
|
+
const northStar = args.north_star;
|
|
14995
|
+
if (northStar && northStar.trim()) {
|
|
14996
|
+
if (!adapter2.upsertNorthStar) {
|
|
14997
|
+
return errorResponse("North Star write is not available on this adapter (requires the pg/proxy or md adapter).");
|
|
14998
|
+
}
|
|
14999
|
+
const health = await adapter2.getCycleHealth();
|
|
15000
|
+
const cycleNumber = health.totalCycles;
|
|
15001
|
+
await adapter2.upsertNorthStar(northStar.trim(), cycleNumber);
|
|
15002
|
+
return textResponse(
|
|
15003
|
+
`**North Star set** (Cycle ${cycleNumber})
|
|
15004
|
+
|
|
15005
|
+
${northStar.trim()}
|
|
15006
|
+
|
|
15007
|
+
orient and the project foundation will read this value.`
|
|
15008
|
+
);
|
|
15009
|
+
}
|
|
14502
15010
|
const text2 = args.text;
|
|
14503
15011
|
if (!text2 || !text2.trim()) {
|
|
14504
|
-
return errorResponse("text is required for capture mode. Describe the decision.");
|
|
15012
|
+
return errorResponse("text is required for capture mode. Describe the decision (or pass north_star to set the project North Star).");
|
|
14505
15013
|
}
|
|
14506
15014
|
const adId = args.ad_id;
|
|
14507
15015
|
const confidence = args.confidence ?? "MEDIUM";
|
|
@@ -14612,7 +15120,17 @@ async function viewBoard(adapter2, phaseFilter, options) {
|
|
|
14612
15120
|
const allTasks = await adapter2.queryBoard(
|
|
14613
15121
|
Object.keys(queryOptions).length > 0 ? queryOptions : void 0
|
|
14614
15122
|
);
|
|
14615
|
-
|
|
15123
|
+
let filtered = allTasks;
|
|
15124
|
+
if (options?.cycle != null) {
|
|
15125
|
+
filtered = filtered.filter((t) => t.cycle === options.cycle);
|
|
15126
|
+
}
|
|
15127
|
+
if (options?.query && options.query.trim()) {
|
|
15128
|
+
const q = options.query.trim().toLowerCase();
|
|
15129
|
+
filtered = filtered.filter(
|
|
15130
|
+
(t) => t.title.toLowerCase().includes(q) || (t.notes?.toLowerCase().includes(q) ?? false)
|
|
15131
|
+
);
|
|
15132
|
+
}
|
|
15133
|
+
filtered.sort((a, b2) => {
|
|
14616
15134
|
const aTier = STATUS_TIER[a.status] ?? 4;
|
|
14617
15135
|
const bTier = STATUS_TIER[b2.status] ?? 4;
|
|
14618
15136
|
if (aTier !== bTier) return aTier - bTier;
|
|
@@ -14624,10 +15142,10 @@ async function viewBoard(adapter2, phaseFilter, options) {
|
|
|
14624
15142
|
const bDate = b2.createdAt ? String(b2.createdAt) : "";
|
|
14625
15143
|
return bDate.localeCompare(aDate);
|
|
14626
15144
|
});
|
|
14627
|
-
const total =
|
|
15145
|
+
const total = filtered.length;
|
|
14628
15146
|
const offset = options?.offset ?? 0;
|
|
14629
15147
|
const limit = options?.limit ?? 50;
|
|
14630
|
-
const paged =
|
|
15148
|
+
const paged = filtered.slice(offset, offset + limit);
|
|
14631
15149
|
return {
|
|
14632
15150
|
tasks: paged,
|
|
14633
15151
|
total,
|
|
@@ -14684,20 +15202,32 @@ async function archiveTasks(adapter2, phases, statuses) {
|
|
|
14684
15202
|
// src/tools/board.ts
|
|
14685
15203
|
var boardViewTool = {
|
|
14686
15204
|
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
|
|
15205
|
+
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
15206
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
14689
15207
|
inputSchema: {
|
|
14690
15208
|
type: "object",
|
|
14691
15209
|
properties: {
|
|
14692
|
-
|
|
15210
|
+
task_id: {
|
|
14693
15211
|
type: "string",
|
|
14694
|
-
description: '
|
|
15212
|
+
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.'
|
|
14695
15213
|
},
|
|
14696
|
-
|
|
15214
|
+
query: {
|
|
14697
15215
|
type: "string",
|
|
14698
|
-
description:
|
|
15216
|
+
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."
|
|
14699
15217
|
},
|
|
14700
|
-
|
|
15218
|
+
cycle: {
|
|
15219
|
+
type: "number",
|
|
15220
|
+
description: "Filter to tasks assigned to this cycle number."
|
|
15221
|
+
},
|
|
15222
|
+
phase: {
|
|
15223
|
+
type: "string",
|
|
15224
|
+
description: 'Filter to tasks in this phase (e.g. "Phase 5").'
|
|
15225
|
+
},
|
|
15226
|
+
status: {
|
|
15227
|
+
type: "string",
|
|
15228
|
+
description: 'Status filter: omit for active tasks only, "all" for everything, or comma-separated statuses (e.g. "In Progress,Backlog").'
|
|
15229
|
+
},
|
|
15230
|
+
limit: {
|
|
14701
15231
|
type: "number",
|
|
14702
15232
|
description: "Max tasks to return (default: 50)."
|
|
14703
15233
|
},
|
|
@@ -14828,6 +15358,16 @@ var boardEditTool = {
|
|
|
14828
15358
|
cycle: {
|
|
14829
15359
|
type: ["number", "null"],
|
|
14830
15360
|
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."
|
|
15361
|
+
},
|
|
15362
|
+
estimated_effort: {
|
|
15363
|
+
type: "string",
|
|
15364
|
+
enum: ["XS", "S", "M", "L", "XL"],
|
|
15365
|
+
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."
|
|
15366
|
+
},
|
|
15367
|
+
actual_effort: {
|
|
15368
|
+
type: "string",
|
|
15369
|
+
enum: ["XS", "S", "M", "L", "XL"],
|
|
15370
|
+
description: "task-2182: correct the actual effort on this task's LATEST build report (fixes a mis-recorded actual)."
|
|
14831
15371
|
}
|
|
14832
15372
|
},
|
|
14833
15373
|
required: ["task_id"]
|
|
@@ -14836,6 +15376,27 @@ var boardEditTool = {
|
|
|
14836
15376
|
function pad(value, width) {
|
|
14837
15377
|
return value.length >= width ? value : value + " ".repeat(width - value.length);
|
|
14838
15378
|
}
|
|
15379
|
+
var TITLE_MAX = 80;
|
|
15380
|
+
function truncateTitle(title) {
|
|
15381
|
+
return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}\u2026` : title;
|
|
15382
|
+
}
|
|
15383
|
+
function formatSingleTask(t) {
|
|
15384
|
+
const lines = [
|
|
15385
|
+
`**${t.id} \u2014 ${t.title}**`,
|
|
15386
|
+
"",
|
|
15387
|
+
`- **Status:** ${t.status}`,
|
|
15388
|
+
`- **Priority:** ${t.priority}`,
|
|
15389
|
+
`- **Cycle:** ${t.cycle != null ? t.cycle : "-"}`,
|
|
15390
|
+
`- **Phase:** ${t.phase ?? "-"}`,
|
|
15391
|
+
`- **Module:** ${t.module ?? "-"}`,
|
|
15392
|
+
`- **Epic:** ${t.epic ?? "-"}`,
|
|
15393
|
+
`- **Effort:** ${t.complexity ?? "-"}`,
|
|
15394
|
+
`- **Source:** ${t.source ?? "-"}`
|
|
15395
|
+
];
|
|
15396
|
+
if (t.notes?.trim()) lines.push(`- **Notes:** ${t.notes.trim()}`);
|
|
15397
|
+
if (t.why?.trim()) lines.push(`- **Why:** ${t.why.trim()}`);
|
|
15398
|
+
return lines.join("\n");
|
|
15399
|
+
}
|
|
14839
15400
|
function formatBoard(result) {
|
|
14840
15401
|
if (result.tasks.length === 0) {
|
|
14841
15402
|
return "No tasks found.";
|
|
@@ -14844,7 +15405,7 @@ function formatBoard(result) {
|
|
|
14844
15405
|
const rows = result.tasks.map((t) => [
|
|
14845
15406
|
t.priority,
|
|
14846
15407
|
t.id,
|
|
14847
|
-
t.title,
|
|
15408
|
+
truncateTitle(t.title),
|
|
14848
15409
|
t.status,
|
|
14849
15410
|
t.cycle != null ? String(t.cycle) : "-",
|
|
14850
15411
|
t.phase ?? "-",
|
|
@@ -14896,11 +15457,19 @@ async function handleBoardView(adapter2, args) {
|
|
|
14896
15457
|
const summary = await viewBoardSummary(adapter2);
|
|
14897
15458
|
return textResponse(formatSummary(summary));
|
|
14898
15459
|
}
|
|
15460
|
+
const taskIdArg = args.task_id ?? args.display_id;
|
|
15461
|
+
if (taskIdArg) {
|
|
15462
|
+
const task = await adapter2.getTask(taskIdArg);
|
|
15463
|
+
if (!task) return errorResponse(`Task ${taskIdArg} not found.`);
|
|
15464
|
+
return textResponse(formatSingleTask(task));
|
|
15465
|
+
}
|
|
14899
15466
|
const result = await viewBoard(adapter2, void 0, {
|
|
14900
15467
|
phase: args.phase,
|
|
14901
15468
|
status: args.status,
|
|
14902
15469
|
limit: args.limit,
|
|
14903
|
-
offset: args.offset
|
|
15470
|
+
offset: args.offset,
|
|
15471
|
+
query: args.query,
|
|
15472
|
+
cycle: args.cycle
|
|
14904
15473
|
});
|
|
14905
15474
|
let output = formatBoard(result);
|
|
14906
15475
|
try {
|
|
@@ -15022,6 +15591,15 @@ async function handleBoardEdit(adapter2, args) {
|
|
|
15022
15591
|
changes.push(field);
|
|
15023
15592
|
}
|
|
15024
15593
|
}
|
|
15594
|
+
const effortCorrection = {};
|
|
15595
|
+
if (typeof args.estimated_effort === "string") {
|
|
15596
|
+
effortCorrection.estimatedEffort = args.estimated_effort;
|
|
15597
|
+
changes.push("estimated_effort");
|
|
15598
|
+
}
|
|
15599
|
+
if (typeof args.actual_effort === "string") {
|
|
15600
|
+
effortCorrection.actualEffort = args.actual_effort;
|
|
15601
|
+
changes.push("actual_effort");
|
|
15602
|
+
}
|
|
15025
15603
|
const notesMode = args.notes_mode;
|
|
15026
15604
|
if (notesMode === "clear" && !changes.includes("notes")) {
|
|
15027
15605
|
changes.push("notes");
|
|
@@ -15106,7 +15684,15 @@ ${existing}` : entry;
|
|
|
15106
15684
|
if (updates.status === "Cancelled" && !updates.cancelledBy) {
|
|
15107
15685
|
updates.cancelledBy = "user";
|
|
15108
15686
|
}
|
|
15109
|
-
|
|
15687
|
+
if (effortCorrection.estimatedEffort || effortCorrection.actualEffort) {
|
|
15688
|
+
if (!adapter2.correctLatestBuildReportEffort) {
|
|
15689
|
+
return errorResponse("Correcting build-report effort requires a database adapter (pg). The md adapter does not support it.");
|
|
15690
|
+
}
|
|
15691
|
+
await adapter2.correctLatestBuildReportEffort(taskId, effortCorrection);
|
|
15692
|
+
}
|
|
15693
|
+
if (Object.keys(updates).length > 0) {
|
|
15694
|
+
await adapter2.updateTask(taskId, updates);
|
|
15695
|
+
}
|
|
15110
15696
|
if ((updates.status === "Done" || updates.status === "Cancelled") && adapter2.updateDogfoodEntryStatus) {
|
|
15111
15697
|
try {
|
|
15112
15698
|
const dogfoodLog = await adapter2.getDogfoodLog?.(50) ?? [];
|
|
@@ -15383,7 +15969,7 @@ This is a one-time check at the start of the cycle, not per-task. It catches sco
|
|
|
15383
15969
|
Every 5 cycles, PAPI offers a strategy review \u2014 a deep analysis of velocity, estimation accuracy, active decisions, and project direction.
|
|
15384
15970
|
|
|
15385
15971
|
- **Don't skip them.** They're where compounding value comes from.
|
|
15386
|
-
-
|
|
15972
|
+
- 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
15973
|
- Reviews produce recommendations that feed into the next plan.
|
|
15388
15974
|
- If the review recommends AD changes, use \`strategy_change\` to apply them.
|
|
15389
15975
|
|
|
@@ -15781,6 +16367,9 @@ async function ensurePapiPermission(projectRoot) {
|
|
|
15781
16367
|
} catch {
|
|
15782
16368
|
}
|
|
15783
16369
|
}
|
|
16370
|
+
var TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
|
|
16371
|
+
var CONVENTIONS_SENTINEL = "<!-- PAPI_CONVENTIONS -->";
|
|
16372
|
+
var CONVENTIONS_HEADING = "## Code Style Conventions";
|
|
15784
16373
|
async function applySetupOutputs(adapter2, config2, input, collector, briefText, adSeedText, conventionsText) {
|
|
15785
16374
|
const warnings = [];
|
|
15786
16375
|
await adapter2.updateProductBrief(briefText);
|
|
@@ -15818,13 +16407,21 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
|
|
|
15818
16407
|
}
|
|
15819
16408
|
}
|
|
15820
16409
|
let seededAds = 0;
|
|
16410
|
+
let skippedAds = 0;
|
|
15821
16411
|
if (adSeedText) {
|
|
15822
16412
|
try {
|
|
15823
16413
|
const cleaned = adSeedText.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
|
|
15824
16414
|
const ads = JSON.parse(cleaned);
|
|
15825
16415
|
if (Array.isArray(ads)) {
|
|
16416
|
+
const existingAdIds = adapter2.getActiveDecisions ? new Set(
|
|
16417
|
+
(await adapter2.getActiveDecisions({ includeRetired: true }).catch(() => [])).map((a) => a.displayId)
|
|
16418
|
+
) : /* @__PURE__ */ new Set();
|
|
15826
16419
|
for (const ad of ads) {
|
|
15827
16420
|
if (ad.id && ad.body) {
|
|
16421
|
+
if (existingAdIds.has(ad.id) && !input.force) {
|
|
16422
|
+
skippedAds++;
|
|
16423
|
+
continue;
|
|
16424
|
+
}
|
|
15828
16425
|
if (adapter2.upsertActiveDecision) {
|
|
15829
16426
|
const title = ad.title || ad.body.split("\n")[0].replace(/^#+\s*/, "").slice(0, 120);
|
|
15830
16427
|
await adapter2.upsertActiveDecision(ad.id, ad.body, title, ad.confidence || "MEDIUM", 0);
|
|
@@ -15842,7 +16439,11 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
|
|
|
15842
16439
|
);
|
|
15843
16440
|
seededAds = 0;
|
|
15844
16441
|
}
|
|
15845
|
-
if (
|
|
16442
|
+
if (skippedAds > 0) {
|
|
16443
|
+
warnings.push(
|
|
16444
|
+
`Active Decisions: detected ${skippedAds} existing AD(s) and left them untouched${seededAds > 0 ? `; created ${seededAds} new one(s)` : " (none new to create)"}.`
|
|
16445
|
+
);
|
|
16446
|
+
} else if (seededAds === 0 && adSeedText) {
|
|
15846
16447
|
if (!warnings.some((w) => w.startsWith("AD seeding failed"))) {
|
|
15847
16448
|
warnings.push(
|
|
15848
16449
|
"AD seeding produced 0 active decisions \u2014 the JSON may be valid but empty or missing required `id` and `body` fields."
|
|
@@ -15851,17 +16452,29 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
|
|
|
15851
16452
|
}
|
|
15852
16453
|
}
|
|
15853
16454
|
if (conventionsText?.trim()) {
|
|
16455
|
+
const conventionsBlock = `${CONVENTIONS_SENTINEL}
|
|
16456
|
+
${conventionsText.trim()}
|
|
16457
|
+
`;
|
|
15854
16458
|
if (config2.adapterType === "proxy") {
|
|
15855
16459
|
collector.add({
|
|
15856
16460
|
path: "CLAUDE.md",
|
|
15857
|
-
content: "\n" +
|
|
16461
|
+
content: "\n" + conventionsBlock,
|
|
15858
16462
|
mode: "append"
|
|
15859
16463
|
});
|
|
16464
|
+
warnings.push(
|
|
16465
|
+
`Conventions: append the block to CLAUDE.md only if it does not already contain \`${CONVENTIONS_SENTINEL}\` or a "${CONVENTIONS_HEADING}" section.`
|
|
16466
|
+
);
|
|
15860
16467
|
} else {
|
|
15861
16468
|
try {
|
|
15862
16469
|
const claudeMdPath = join5(config2.projectRoot, "CLAUDE.md");
|
|
15863
16470
|
const existing = await readFile4(claudeMdPath, "utf-8");
|
|
15864
|
-
|
|
16471
|
+
if (existing.includes(CONVENTIONS_SENTINEL) || existing.includes(CONVENTIONS_HEADING)) {
|
|
16472
|
+
warnings.push(
|
|
16473
|
+
"Conventions: CLAUDE.md already contains a conventions block \u2014 skipped append to avoid duplication."
|
|
16474
|
+
);
|
|
16475
|
+
} else {
|
|
16476
|
+
await writeFile2(claudeMdPath, existing + "\n" + conventionsBlock, "utf-8");
|
|
16477
|
+
}
|
|
15865
16478
|
} catch {
|
|
15866
16479
|
}
|
|
15867
16480
|
}
|
|
@@ -16048,8 +16661,8 @@ async function prepareSetup(adapter2, config2, input) {
|
|
|
16048
16661
|
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
16662
|
);
|
|
16050
16663
|
}
|
|
16051
|
-
const
|
|
16052
|
-
const briefHasRealContent = existingBrief.trim().length > 0 && !existingBrief.includes(
|
|
16664
|
+
const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
|
|
16665
|
+
const briefHasRealContent = existingBrief.trim().length > 0 && !existingBrief.includes(TEMPLATE_MARKER2);
|
|
16053
16666
|
const briefAlreadyExists = briefHasRealContent && !input.force;
|
|
16054
16667
|
const canScanFilesystem = hasLocalWorkspace();
|
|
16055
16668
|
const warnings = [];
|
|
@@ -16160,6 +16773,15 @@ async function prepareSetup(adapter2, config2, input) {
|
|
|
16160
16773
|
targetUsers: input.targetUsers,
|
|
16161
16774
|
codebaseContext: codebaseSummary
|
|
16162
16775
|
})
|
|
16776
|
+
} : input.description?.trim() ? {
|
|
16777
|
+
system: VISION_TASKS_SYSTEM,
|
|
16778
|
+
user: buildVisionTasksPrompt({
|
|
16779
|
+
projectName: input.projectName,
|
|
16780
|
+
description: input.description,
|
|
16781
|
+
targetUsers: input.targetUsers,
|
|
16782
|
+
problems: input.problems,
|
|
16783
|
+
projectType: input.projectType
|
|
16784
|
+
})
|
|
16163
16785
|
} : void 0;
|
|
16164
16786
|
return {
|
|
16165
16787
|
createdProject,
|
|
@@ -16181,7 +16803,6 @@ async function prepareSetup(adapter2, config2, input) {
|
|
|
16181
16803
|
async function applySetup(adapter2, config2, input, briefText, adSeedText, conventionsText, initialTasksText) {
|
|
16182
16804
|
const collector = new FileWriteCollector();
|
|
16183
16805
|
const createdProject = await scaffoldPapiDir(adapter2, config2, input, collector);
|
|
16184
|
-
const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
|
|
16185
16806
|
let effectiveBriefText = briefText;
|
|
16186
16807
|
let briefRegenerated = false;
|
|
16187
16808
|
if (!effectiveBriefText.trim()) {
|
|
@@ -16240,7 +16861,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
|
|
|
16240
16861
|
`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
16862
|
);
|
|
16242
16863
|
}
|
|
16243
|
-
const MAX_INITIAL_TASKS =
|
|
16864
|
+
const MAX_INITIAL_TASKS = 20;
|
|
16244
16865
|
const failedTasks = [];
|
|
16245
16866
|
for (const task of toCreate.slice(0, MAX_INITIAL_TASKS)) {
|
|
16246
16867
|
try {
|
|
@@ -16516,10 +17137,11 @@ ${result.seededAds} Active Decision${result.seededAds > 1 ? "s" : ""} seeded bas
|
|
|
16516
17137
|
const taskNote = result.createdTasks > 0 || (result.tasksSkipped ?? 0) > 0 ? (() => {
|
|
16517
17138
|
const created = result.createdTasks > 0 ? `${result.createdTasks} initial backlog task${result.createdTasks > 1 ? "s" : ""} created` : "";
|
|
16518
17139
|
const skipped = (result.tasksSkipped ?? 0) > 0 ? `${result.tasksSkipped} duplicate${(result.tasksSkipped ?? 0) > 1 ? "s" : ""} skipped` : "";
|
|
17140
|
+
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
17141
|
return `
|
|
16520
17142
|
|
|
16521
|
-
${[created, skipped].filter(Boolean).join(", ")}
|
|
16522
|
-
})() : "";
|
|
17143
|
+
${[created, skipped].filter(Boolean).join(", ")}.${idea}`;
|
|
17144
|
+
})() : '\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
17145
|
const constraintsHint = !constraints ? '\n\nTip: consider adding `constraints` (e.g. "must use PostgreSQL", "HIPAA compliant", "offline-first") to improve Active Decision seeding.' : "";
|
|
16524
17146
|
const editorNote = result.cursorScaffolded ? "\n\nCursor detected \u2014 `.cursor/rules/papi.mdc` scaffolded alongside CLAUDE.md." : "";
|
|
16525
17147
|
const gitignoreNote = result.gitignoreNote ? `
|
|
@@ -16619,6 +17241,17 @@ PAPI needs the project name. Description and target users are optional \u2014 th
|
|
|
16619
17241
|
""
|
|
16620
17242
|
);
|
|
16621
17243
|
}
|
|
17244
|
+
if (result.briefPrompt && !result.briefAlreadyExists) {
|
|
17245
|
+
sections.push(
|
|
17246
|
+
`**\u{1F4C4} Before you write the brief \u2014 does the user already have a PRD, brief, spec, or design doc?**`,
|
|
17247
|
+
`A brief generated from the user's real spec produces a far better first plan than a generic scaffold.`,
|
|
17248
|
+
`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."`,
|
|
17249
|
+
`2. **Scan the working directory yourself** for likely files and offer them: \`PRD*\`, \`README*\`, \`SPEC*\`/\`spec*\`, design docs, and \`*.md\` under \`docs/\`.`,
|
|
17250
|
+
`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.`,
|
|
17251
|
+
`If there's no existing doc, skip this and generate the brief from what you know \u2014 the zero-doc path is fully supported.`,
|
|
17252
|
+
""
|
|
17253
|
+
);
|
|
17254
|
+
}
|
|
16622
17255
|
sections.push(
|
|
16623
17256
|
`Generate the outputs below, then call \`setup\` again with:`,
|
|
16624
17257
|
`- \`mode\`: "apply"`,
|
|
@@ -17000,8 +17633,16 @@ function inferCycleFromVersion(version) {
|
|
|
17000
17633
|
const m = version.match(/^v0\.(\d+)\./);
|
|
17001
17634
|
return m ? parseInt(m[1], 10) : 0;
|
|
17002
17635
|
}
|
|
17003
|
-
async function resolveCycleToClose(adapter2, version) {
|
|
17636
|
+
async function resolveCycleToClose(adapter2, version, callerUserId) {
|
|
17004
17637
|
if (adapter2) {
|
|
17638
|
+
if (callerUserId) {
|
|
17639
|
+
try {
|
|
17640
|
+
const mine = callerUserId.trim().toLowerCase();
|
|
17641
|
+
const owned = (await adapter2.readCycles()).filter((c) => c.status === "active" && c.userId?.trim().toLowerCase() === mine).map((c) => c.number);
|
|
17642
|
+
if (owned.length > 0) return Math.max(...owned);
|
|
17643
|
+
} catch {
|
|
17644
|
+
}
|
|
17645
|
+
}
|
|
17005
17646
|
try {
|
|
17006
17647
|
const health = await adapter2.getCycleHealth();
|
|
17007
17648
|
if (health.totalCycles > 0) return health.totalCycles;
|
|
@@ -17010,21 +17651,11 @@ async function resolveCycleToClose(adapter2, version) {
|
|
|
17010
17651
|
}
|
|
17011
17652
|
return inferCycleFromVersion(version);
|
|
17012
17653
|
}
|
|
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
|
-
}
|
|
17654
|
+
async function closeCycleState(config2, adapter2, version, cycleNum, options) {
|
|
17024
17655
|
const warnings = [];
|
|
17025
17656
|
const force = options?.force ?? false;
|
|
17026
17657
|
const skipVersion = options?.skipVersion ?? false;
|
|
17027
|
-
const resolvedCycleNum = cycleNum && cycleNum > 0 ? cycleNum : await resolveCycleToClose(adapter2, version);
|
|
17658
|
+
const resolvedCycleNum = cycleNum && cycleNum > 0 ? cycleNum : await resolveCycleToClose(adapter2, version, options?.callerUserId);
|
|
17028
17659
|
const versionRegexCycle = inferCycleFromVersion(version);
|
|
17029
17660
|
if (resolvedCycleNum > 0 && versionRegexCycle > 0 && versionRegexCycle !== resolvedCycleNum) {
|
|
17030
17661
|
warnings.push(
|
|
@@ -17068,30 +17699,6 @@ To override, pass force=true (emits a telemetry warning).`
|
|
|
17068
17699
|
warnings.push(`Release-readiness check failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
17069
17700
|
}
|
|
17070
17701
|
}
|
|
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
17702
|
if (adapter2) {
|
|
17096
17703
|
const currentCycle = resolvedCycleNum;
|
|
17097
17704
|
if (currentCycle > 0) {
|
|
@@ -17115,9 +17722,64 @@ To override, pass force=true (emits a telemetry warning).`
|
|
|
17115
17722
|
const msg = `createCycle (mark complete) failed for cycle ${currentCycle}: ${err instanceof Error ? err.message : String(err)}`;
|
|
17116
17723
|
console.error(`[release] ${msg}`);
|
|
17117
17724
|
throw new Error(
|
|
17118
|
-
`Release blocked \u2014 could not mark cycle ${currentCycle} complete in the DB
|
|
17725
|
+
`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\`.`
|
|
17726
|
+
);
|
|
17727
|
+
}
|
|
17728
|
+
if (adapter2.appendCycleMetrics && adapter2.getBuildReportsSince) {
|
|
17729
|
+
try {
|
|
17730
|
+
const cycleReports = (await adapter2.getBuildReportsSince(currentCycle)).filter((r) => r.cycle === currentCycle);
|
|
17731
|
+
const [snapshot] = computeSnapshotsFromBuildReports(cycleReports);
|
|
17732
|
+
if (snapshot) await adapter2.appendCycleMetrics(snapshot);
|
|
17733
|
+
} catch (err) {
|
|
17734
|
+
console.error(
|
|
17735
|
+
`[release] cycle-metrics snapshot write failed for cycle ${currentCycle} (non-blocking): ${err instanceof Error ? err.message : String(err)}`
|
|
17736
|
+
);
|
|
17737
|
+
}
|
|
17738
|
+
}
|
|
17739
|
+
}
|
|
17740
|
+
}
|
|
17741
|
+
return { resolvedCycleNum, warnings, force, skipVersion };
|
|
17742
|
+
}
|
|
17743
|
+
async function createRelease(config2, branch, version, adapter2, cycleNum, options) {
|
|
17744
|
+
const collector = new FileWriteCollector();
|
|
17745
|
+
if (!isGitAvailable()) {
|
|
17746
|
+
throw new Error("git is not available.");
|
|
17747
|
+
}
|
|
17748
|
+
if (!isGitRepo(config2.projectRoot)) {
|
|
17749
|
+
throw new Error("not a git repository.");
|
|
17750
|
+
}
|
|
17751
|
+
if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
|
|
17752
|
+
throw new Error("working directory has uncommitted changes. Commit or stash them before releasing.");
|
|
17753
|
+
}
|
|
17754
|
+
const { resolvedCycleNum, warnings, skipVersion } = await closeCycleState(
|
|
17755
|
+
config2,
|
|
17756
|
+
adapter2,
|
|
17757
|
+
version,
|
|
17758
|
+
cycleNum,
|
|
17759
|
+
options
|
|
17760
|
+
);
|
|
17761
|
+
if (adapter2) {
|
|
17762
|
+
try {
|
|
17763
|
+
const resolvedBase = resolveBaseBranch(config2.projectRoot, branch);
|
|
17764
|
+
const orphans = listOrphanFeatBranches(config2.projectRoot, resolvedBase);
|
|
17765
|
+
const allDoneOrphans = [];
|
|
17766
|
+
for (const orphanBranch of orphans) {
|
|
17767
|
+
const taskIds = getTaskIdsOnBranch(config2.projectRoot, orphanBranch, resolvedBase);
|
|
17768
|
+
if (taskIds.length === 0) continue;
|
|
17769
|
+
const tasks = await adapter2.getTasks(taskIds);
|
|
17770
|
+
if (tasks.length === 0) continue;
|
|
17771
|
+
const allDone = tasks.every((t) => t.status === "Done");
|
|
17772
|
+
if (allDone) {
|
|
17773
|
+
allDoneOrphans.push(`${orphanBranch} (${taskIds.join(", ")})`);
|
|
17774
|
+
}
|
|
17775
|
+
}
|
|
17776
|
+
if (allDoneOrphans.length > 0) {
|
|
17777
|
+
warnings.push(
|
|
17778
|
+
`\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("; ")}`
|
|
17119
17779
|
);
|
|
17120
17780
|
}
|
|
17781
|
+
} catch (err) {
|
|
17782
|
+
warnings.push(`Orphan-branch detection failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
17121
17783
|
}
|
|
17122
17784
|
}
|
|
17123
17785
|
const checkout = checkoutBranch(config2.projectRoot, branch);
|
|
@@ -17148,6 +17810,21 @@ To override, pass force=true (emits a telemetry warning).`
|
|
|
17148
17810
|
}
|
|
17149
17811
|
}
|
|
17150
17812
|
}
|
|
17813
|
+
if (adapter2 && resolvedCycleNum > 0) {
|
|
17814
|
+
try {
|
|
17815
|
+
const stampedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
17816
|
+
const doneCycleTasks = (await adapter2.queryBoard()).filter(
|
|
17817
|
+
(t) => t.cycle === resolvedCycleNum && t.status === "Done"
|
|
17818
|
+
);
|
|
17819
|
+
for (const t of doneCycleTasks) {
|
|
17820
|
+
try {
|
|
17821
|
+
await adapter2.updateTask(t.id, { mergedAt: stampedAt });
|
|
17822
|
+
} catch {
|
|
17823
|
+
}
|
|
17824
|
+
}
|
|
17825
|
+
} catch {
|
|
17826
|
+
}
|
|
17827
|
+
}
|
|
17151
17828
|
const latestTag = getLatestTag(config2.projectRoot);
|
|
17152
17829
|
const changelogPath = join7(config2.projectRoot, "CHANGELOG.md");
|
|
17153
17830
|
if (!latestTag) {
|
|
@@ -17207,60 +17884,6 @@ To override, pass force=true (emits a telemetry warning).`
|
|
|
17207
17884
|
// src/tools/release.ts
|
|
17208
17885
|
init_git();
|
|
17209
17886
|
|
|
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
17887
|
// src/lib/registry-updates.ts
|
|
17265
17888
|
import { execFile } from "child_process";
|
|
17266
17889
|
import { promisify } from "util";
|
|
@@ -17371,21 +17994,32 @@ function parseGithubOwner(input) {
|
|
|
17371
17994
|
function sanitiseBranchSuffix(branch) {
|
|
17372
17995
|
return branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
17373
17996
|
}
|
|
17374
|
-
|
|
17375
|
-
|
|
17376
|
-
|
|
17377
|
-
|
|
17378
|
-
const
|
|
17379
|
-
|
|
17380
|
-
|
|
17381
|
-
|
|
17382
|
-
|
|
17383
|
-
|
|
17384
|
-
})
|
|
17385
|
-
|
|
17386
|
-
|
|
17387
|
-
|
|
17388
|
-
|
|
17997
|
+
var CYCLE_UPDATES_CHANNEL_ENV = "DISCORD_CYCLE_UPDATES_CHANNEL_ID";
|
|
17998
|
+
function buildCycleUpdateCurationDirective(version, cycleClosed) {
|
|
17999
|
+
const channelId = process.env[CYCLE_UPDATES_CHANNEL_ENV]?.trim();
|
|
18000
|
+
if (!channelId) return null;
|
|
18001
|
+
const cyclePart = cycleClosed > 0 ? `Cycle ${cycleClosed}` : "New release";
|
|
18002
|
+
return [
|
|
18003
|
+
"",
|
|
18004
|
+
"---",
|
|
18005
|
+
"## \u{1F4E3} Post a curated cycle update to Discord #papi-cycle-updates",
|
|
18006
|
+
"",
|
|
18007
|
+
`**${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\`.`,
|
|
18008
|
+
"",
|
|
18009
|
+
"**LOCKED TEMPLATE \u2014 every cycle update uses this exact shape so they stay consistent (do NOT improvise the structure):**",
|
|
18010
|
+
`- \`title\`: \`${cyclePart} shipped \u2014 ${version}\``,
|
|
18011
|
+
"- `url`: `https://getpapi.ai/changelog` (makes the title link to the changelog)",
|
|
18012
|
+
"- `color`: `#5a4b8a` (Papi plum)",
|
|
18013
|
+
"- `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.",
|
|
18014
|
+
`- \`footer\`: \`Built with PAPI \xB7 <N> tasks \xB7 getpapi.ai\` where <N> = the count of THIS cycle's completed tasks (${cyclePart}).`,
|
|
18015
|
+
"",
|
|
18016
|
+
"Then:",
|
|
18017
|
+
"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.)",
|
|
18018
|
+
"2. Show the draft embed for a quick approval BEFORE posting.",
|
|
18019
|
+
`3. On approval, send the embed to channel \`${channelId}\`.`,
|
|
18020
|
+
"",
|
|
18021
|
+
"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."
|
|
18022
|
+
].join("\n");
|
|
17389
18023
|
}
|
|
17390
18024
|
async function postReleaseToX(version, cycleClosed) {
|
|
17391
18025
|
const url = process.env["X_RELEASE_WEBHOOK_URL"]?.trim();
|
|
@@ -17469,14 +18103,48 @@ async function handleRelease(adapter2, config2, args) {
|
|
|
17469
18103
|
version = `${version}-${suffix}`;
|
|
17470
18104
|
}
|
|
17471
18105
|
}
|
|
17472
|
-
|
|
17473
|
-
|
|
17474
|
-
|
|
17475
|
-
|
|
18106
|
+
const tracker = new ProgressTracker("validate-args");
|
|
18107
|
+
try {
|
|
18108
|
+
tracker.mark("owner-identity-guard");
|
|
18109
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
18110
|
+
const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
|
|
18111
|
+
if (branch === productionBaseBranch) {
|
|
18112
|
+
if (gate.enforced && !gate.callerIsOwner) {
|
|
18113
|
+
const resolutionNote = gate.resolutionError ? `
|
|
18114
|
+
|
|
18115
|
+
Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
|
|
18116
|
+
return errorResponse(
|
|
18117
|
+
`Release to ${productionBaseBranch} is restricted to the project owner.
|
|
18118
|
+
|
|
18119
|
+
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
|
|
18120
|
+
);
|
|
18121
|
+
}
|
|
18122
|
+
}
|
|
18123
|
+
if (isHostedTransport()) {
|
|
18124
|
+
tracker.mark("hosted-close-cycle");
|
|
18125
|
+
let closed;
|
|
18126
|
+
try {
|
|
18127
|
+
closed = await closeCycleState(config2, adapter2, version, void 0, {
|
|
18128
|
+
force: force ?? false,
|
|
18129
|
+
callerUserId: gate.callerUserId
|
|
18130
|
+
});
|
|
18131
|
+
} catch (err) {
|
|
18132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
18133
|
+
return errorResponse(message);
|
|
18134
|
+
}
|
|
18135
|
+
const tagAnnotation = `Release ${version}`;
|
|
18136
|
+
const cyclePart = closed.resolvedCycleNum > 0 ? `Cycle ${closed.resolvedCycleNum}` : "The cycle";
|
|
18137
|
+
const warningsBlock = closed.warnings.length > 0 ? `
|
|
18138
|
+
\u26A0\uFE0F Warnings: ${closed.warnings.join("; ")}
|
|
18139
|
+
` : "";
|
|
18140
|
+
return textResponse(
|
|
18141
|
+
`## Release ${version} \u2014 cycle closed in the DB
|
|
17476
18142
|
|
|
17477
|
-
|
|
18143
|
+
${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
|
|
18144
|
+
` + warningsBlock + `
|
|
18145
|
+
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
18146
|
|
|
17479
|
-
|
|
18147
|
+
Finish the release locally:
|
|
17480
18148
|
\`\`\`
|
|
17481
18149
|
git checkout ${branch}
|
|
17482
18150
|
git pull
|
|
@@ -17490,11 +18158,9 @@ If git reports "Author identity unknown" or "Committer identity unknown" (no glo
|
|
|
17490
18158
|
git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
|
|
17491
18159
|
\`\`\`
|
|
17492
18160
|
|
|
17493
|
-
|
|
17494
|
-
|
|
17495
|
-
|
|
17496
|
-
const tracker = new ProgressTracker("validate-args");
|
|
17497
|
-
try {
|
|
18161
|
+
Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`
|
|
18162
|
+
);
|
|
18163
|
+
}
|
|
17498
18164
|
tracker.mark("remote-project-guard");
|
|
17499
18165
|
if (adapter2.getProjectInfo) {
|
|
17500
18166
|
try {
|
|
@@ -17516,21 +18182,6 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
|
|
|
17516
18182
|
console.error("[release] remote-project guard: skipped due to error \u2014 " + (err instanceof Error ? err.message : String(err)));
|
|
17517
18183
|
}
|
|
17518
18184
|
}
|
|
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
18185
|
tracker.mark("preflight-grouped-branches");
|
|
17535
18186
|
const cycleMatch = version.match(/^v0\.(\d+)\./);
|
|
17536
18187
|
const cycleNumForPreflight = cycleMatch ? parseInt(cycleMatch[1], 10) : void 0;
|
|
@@ -17542,7 +18193,11 @@ Your identity does not match this project's owner. If you are a contributor, pus
|
|
|
17542
18193
|
}
|
|
17543
18194
|
}
|
|
17544
18195
|
tracker.mark("create-release");
|
|
17545
|
-
const result = await createRelease(config2, branch, version, adapter2, void 0, {
|
|
18196
|
+
const result = await createRelease(config2, branch, version, adapter2, void 0, {
|
|
18197
|
+
force: force ?? false,
|
|
18198
|
+
skipVersion: skipVersion ?? false,
|
|
18199
|
+
callerUserId: gate.callerUserId
|
|
18200
|
+
});
|
|
17546
18201
|
const lines = [
|
|
17547
18202
|
`## Release ${result.version}${skipVersion ? " (skip version)" : ""}`,
|
|
17548
18203
|
"",
|
|
@@ -17594,11 +18249,8 @@ Your identity does not match this project's owner. If you are a contributor, pus
|
|
|
17594
18249
|
lines.push("", "\u26A0\uFE0F Dogfood observations could not be saved to DB \u2014 log them manually in DOGFOOD_LOG.md.");
|
|
17595
18250
|
}
|
|
17596
18251
|
}
|
|
17597
|
-
|
|
17598
|
-
|
|
17599
|
-
if (posted) lines.push("", "Posted release announcement to Discord #changelog.");
|
|
17600
|
-
} catch {
|
|
17601
|
-
}
|
|
18252
|
+
const cycleUpdateDirective = buildCycleUpdateCurationDirective(result.version, result.cycleClosed ?? 0);
|
|
18253
|
+
if (cycleUpdateDirective) lines.push(cycleUpdateDirective);
|
|
17602
18254
|
try {
|
|
17603
18255
|
const xPosted = await postReleaseToX(result.version, result.cycleClosed ?? 0);
|
|
17604
18256
|
if (xPosted) lines.push("", "Posted release announcement to X.");
|
|
@@ -17618,7 +18270,7 @@ Your identity does not match this project's owner. If you are a contributor, pus
|
|
|
17618
18270
|
}
|
|
17619
18271
|
} catch {
|
|
17620
18272
|
}
|
|
17621
|
-
lines.push("", `Next: cycle released! Run \`plan\` to start your next
|
|
18273
|
+
lines.push("", `Next: cycle released! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`);
|
|
17622
18274
|
const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
|
|
17623
18275
|
return textResponse(lines.join("\n") + filesToWriteSection);
|
|
17624
18276
|
} catch (err) {
|
|
@@ -17746,6 +18398,16 @@ function selectAutostashPaths(modified, untracked) {
|
|
|
17746
18398
|
const stashableUntracked = untracked.filter((p) => !isDocsPath(p));
|
|
17747
18399
|
return { toStash: [...trackedDirty, ...stashableUntracked], preservedDocs };
|
|
17748
18400
|
}
|
|
18401
|
+
function resolveRelatedDecisions(provided, buildHandoff) {
|
|
18402
|
+
const fromInput = (provided ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
18403
|
+
if (fromInput.length > 0) return { adIds: Array.from(new Set(fromInput)), inferred: false };
|
|
18404
|
+
if (buildHandoff) {
|
|
18405
|
+
const text = typeof buildHandoff === "string" ? buildHandoff : JSON.stringify(buildHandoff);
|
|
18406
|
+
const matches = text.match(/\bAD-\d+\b/g);
|
|
18407
|
+
if (matches && matches.length > 0) return { adIds: Array.from(new Set(matches)), inferred: true };
|
|
18408
|
+
}
|
|
18409
|
+
return { adIds: [], inferred: false };
|
|
18410
|
+
}
|
|
17749
18411
|
var buildStartTimes = /* @__PURE__ */ new Map();
|
|
17750
18412
|
var taskBranchMap = /* @__PURE__ */ new Map();
|
|
17751
18413
|
var taskStartShaMap = /* @__PURE__ */ new Map();
|
|
@@ -17761,16 +18423,36 @@ function sanitisePredictedFiles(raw) {
|
|
|
17761
18423
|
const out = [];
|
|
17762
18424
|
for (const entry of raw) {
|
|
17763
18425
|
if (typeof entry !== "string") continue;
|
|
17764
|
-
const
|
|
17765
|
-
|
|
17766
|
-
const
|
|
17767
|
-
|
|
18426
|
+
for (const segment of entry.split(";")) {
|
|
18427
|
+
const altMatches = segment.match(/\(\s*or\s+`([^`]+)`\s*\)/gi) ?? [];
|
|
18428
|
+
for (const alt of altMatches) {
|
|
18429
|
+
const inner = alt.match(/`([^`]+)`/);
|
|
18430
|
+
if (inner) out.push(inner[1].trim());
|
|
18431
|
+
}
|
|
18432
|
+
const cleaned = segment.replace(/`/g, "").replace(/\s*\(.*$/, "").trim();
|
|
18433
|
+
if (cleaned) out.push(cleaned);
|
|
17768
18434
|
}
|
|
17769
|
-
const cleaned = entry.replace(/`/g, "").replace(/\s*\(.*$/, "").trim();
|
|
17770
|
-
if (cleaned) out.push(cleaned);
|
|
17771
18435
|
}
|
|
17772
18436
|
return Array.from(new Set(out));
|
|
17773
18437
|
}
|
|
18438
|
+
function pathBasename(p) {
|
|
18439
|
+
const parts = p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
|
|
18440
|
+
return parts[parts.length - 1] ?? p;
|
|
18441
|
+
}
|
|
18442
|
+
function isPathInPredictedScope(changedPath, predicted) {
|
|
18443
|
+
const changedLower = changedPath.replace(/\\/g, "/").toLowerCase();
|
|
18444
|
+
const changedBase = pathBasename(changedPath);
|
|
18445
|
+
for (const raw of predicted) {
|
|
18446
|
+
const entry = raw.replace(/\\/g, "/").replace(/\/+$/, "").trim();
|
|
18447
|
+
if (!entry) continue;
|
|
18448
|
+
if (pathBasename(entry) === changedBase) return true;
|
|
18449
|
+
const entryLower = entry.toLowerCase();
|
|
18450
|
+
if (changedLower === entryLower || changedLower.startsWith(`${entryLower}/`)) {
|
|
18451
|
+
return true;
|
|
18452
|
+
}
|
|
18453
|
+
}
|
|
18454
|
+
return false;
|
|
18455
|
+
}
|
|
17774
18456
|
function autoCommit(config2, taskId, taskTitle, predictedFiles) {
|
|
17775
18457
|
const cwd = config2.projectRoot;
|
|
17776
18458
|
const message = `feat(${taskId}): ${taskTitle}`;
|
|
@@ -17800,17 +18482,12 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
|
|
|
17800
18482
|
if (modified.length === 0) {
|
|
17801
18483
|
return "Auto-commit: skipped (no working-tree changes).";
|
|
17802
18484
|
}
|
|
17803
|
-
const basename2 = (p) => {
|
|
17804
|
-
const parts = p.split(/[\\/]/);
|
|
17805
|
-
return parts[parts.length - 1] ?? p;
|
|
17806
|
-
};
|
|
17807
18485
|
const dirname6 = (p) => {
|
|
17808
18486
|
const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
|
17809
18487
|
return idx > 0 ? p.slice(0, idx) : "";
|
|
17810
18488
|
};
|
|
17811
18489
|
const cleanedPredicted = sanitisePredictedFiles(predictedFiles);
|
|
17812
|
-
const
|
|
17813
|
-
const scoped = modified.filter((p) => predictedNames.has(basename2(p)));
|
|
18490
|
+
const scoped = modified.filter((p) => isPathInPredictedScope(p, cleanedPredicted));
|
|
17814
18491
|
if (scoped.length === 0) {
|
|
17815
18492
|
const modSample = modified.slice(0, 5).join(", ");
|
|
17816
18493
|
const predSample = cleanedPredicted.slice(0, 5).join(", ");
|
|
@@ -17938,12 +18615,8 @@ function computeScopeDriftSignal(predicted, changed) {
|
|
|
17938
18615
|
if (!predicted || predicted.length === 0) return null;
|
|
17939
18616
|
const filtered = changed.filter((c) => !isAmbientPath(c));
|
|
17940
18617
|
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)));
|
|
18618
|
+
const cleanedPredicted = sanitisePredictedFiles(predicted);
|
|
18619
|
+
const unexpected = filtered.filter((c) => !isPathInPredictedScope(c, cleanedPredicted));
|
|
17947
18620
|
const fraction = unexpected.length / filtered.length;
|
|
17948
18621
|
const triggered = fraction > 0.5 || unexpected.length > 5;
|
|
17949
18622
|
if (!triggered) return null;
|
|
@@ -18020,6 +18693,14 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
|
|
|
18020
18693
|
if (!task) {
|
|
18021
18694
|
throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
|
|
18022
18695
|
}
|
|
18696
|
+
if (task.assigneeId) {
|
|
18697
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
18698
|
+
if (!gate.callerUserId || gate.callerUserId !== task.assigneeId) {
|
|
18699
|
+
throw new Error(
|
|
18700
|
+
`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.`
|
|
18701
|
+
);
|
|
18702
|
+
}
|
|
18703
|
+
}
|
|
18023
18704
|
if (task.status === "Done" || task.status === "Archived") {
|
|
18024
18705
|
throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
|
|
18025
18706
|
}
|
|
@@ -18361,10 +19042,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
|
|
|
18361
19042
|
};
|
|
18362
19043
|
buildStartTimes.delete(taskId);
|
|
18363
19044
|
taskBranchMap.delete(taskId);
|
|
18364
|
-
|
|
18365
|
-
|
|
18366
|
-
|
|
18367
|
-
|
|
19045
|
+
const { adIds: relatedAdIds, inferred: relatedInferred } = resolveRelatedDecisions(
|
|
19046
|
+
input.relatedDecisions,
|
|
19047
|
+
task.buildHandoff
|
|
19048
|
+
);
|
|
19049
|
+
if (relatedAdIds.length > 0) report.relatedDecisions = relatedAdIds;
|
|
19050
|
+
console.error(
|
|
19051
|
+
`[build] task ${taskId} related_decisions: ${relatedAdIds.length} AD(s) (${relatedAdIds.length === 0 ? "none" : relatedInferred ? "inferred from handoff" : "builder-provided"}).`
|
|
19052
|
+
);
|
|
18368
19053
|
if (report.startedAt && report.completedAt && typeof adapter2.getToolCallCount === "function") {
|
|
18369
19054
|
try {
|
|
18370
19055
|
const count = await adapter2.getToolCallCount(report.startedAt, report.completedAt);
|
|
@@ -18863,7 +19548,7 @@ var buildExecuteTool = {
|
|
|
18863
19548
|
},
|
|
18864
19549
|
related_decisions: {
|
|
18865
19550
|
type: "string",
|
|
18866
|
-
description: 'Comma-separated AD IDs
|
|
19551
|
+
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
19552
|
},
|
|
18868
19553
|
handoff_accuracy: {
|
|
18869
19554
|
type: "object",
|
|
@@ -19579,6 +20264,20 @@ async function captureBug(adapter2, input) {
|
|
|
19579
20264
|
}
|
|
19580
20265
|
|
|
19581
20266
|
// src/tools/bug.ts
|
|
20267
|
+
var PAPI_SIGNAL_PATTERNS = [
|
|
20268
|
+
/\bpapi\b/i,
|
|
20269
|
+
/\bmcp\b/i,
|
|
20270
|
+
// Distinctive PAPI MCP tool names (whole-word) — these don't appear in an
|
|
20271
|
+
// everyday project-domain bug report.
|
|
20272
|
+
/\b(orient|build_execute|build_list|build_describe|build_cancel|review_submit|review_list|strategy_review|strategy_change|board_view|board_edit|board_deprioritise|board_reconcile|handoff_generate|doc_register|doc_search|ad_hoc|inventory_sync|scope_brief)\b/i,
|
|
20273
|
+
/\bbuild handoff\b/i,
|
|
20274
|
+
/\bremote connector\b/i
|
|
20275
|
+
];
|
|
20276
|
+
function looksLikePapiBug(text, notes) {
|
|
20277
|
+
const haystack = `${text}
|
|
20278
|
+
${notes ?? ""}`;
|
|
20279
|
+
return PAPI_SIGNAL_PATTERNS.some((re) => re.test(haystack));
|
|
20280
|
+
}
|
|
19582
20281
|
function collectDiagnostics(config2) {
|
|
19583
20282
|
return {
|
|
19584
20283
|
nodeVersion: process.version,
|
|
@@ -19592,7 +20291,7 @@ function collectDiagnostics(config2) {
|
|
|
19592
20291
|
}
|
|
19593
20292
|
var bugTool = {
|
|
19594
20293
|
name: "bug",
|
|
19595
|
-
description:
|
|
20294
|
+
description: 'Report a bug OR submit an idea. Routing: a bug about PAPI itself (a PAPI tool/MCP error, the connector, a handoff/cycle problem) auto-submits UPSTREAM to PAPI maintainers with diagnostics \u2014 you do NOT need to set report=true for these. A bug in the user\'s OWN project auto-files as a Backlog task on their board. Override the routing explicitly: report=true forces upstream, report=false forces the user\'s own board. Set `type` ("bug"/"idea") and optional notify-when-fixed / contact-ok consent for upstream submissions. Does not call the Anthropic API.',
|
|
19596
20295
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
19597
20296
|
inputSchema: {
|
|
19598
20297
|
type: "object",
|
|
@@ -19624,7 +20323,20 @@ var bugTool = {
|
|
|
19624
20323
|
},
|
|
19625
20324
|
report: {
|
|
19626
20325
|
type: "boolean",
|
|
19627
|
-
description: "
|
|
20326
|
+
description: "Routing override. Leave UNSET to auto-route: PAPI-product bugs go upstream to maintainers, project-domain bugs go to the user's board. Set true to force an upstream diagnostic submission; set false to force a task on the user's own board (use false when an auto-detected PAPI bug is actually about the user's project)."
|
|
20327
|
+
},
|
|
20328
|
+
type: {
|
|
20329
|
+
type: "string",
|
|
20330
|
+
enum: ["bug", "idea"],
|
|
20331
|
+
description: 'Report mode only: submission kind \u2014 "bug" (default) or "idea" (feature request / suggestion). Both route to the same upstream PAPI triage.'
|
|
20332
|
+
},
|
|
20333
|
+
notify_when_fixed: {
|
|
20334
|
+
type: "boolean",
|
|
20335
|
+
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)."
|
|
20336
|
+
},
|
|
20337
|
+
contact_ok: {
|
|
20338
|
+
type: "boolean",
|
|
20339
|
+
description: "Report mode only: set true if the user consents to the PAPI team reaching out about this submission."
|
|
19628
20340
|
},
|
|
19629
20341
|
project: {
|
|
19630
20342
|
type: "string",
|
|
@@ -19639,10 +20351,23 @@ async function handleBug(adapter2, config2, args) {
|
|
|
19639
20351
|
if (!text) {
|
|
19640
20352
|
return errorResponse("text is required \u2014 describe the bug you want to report.");
|
|
19641
20353
|
}
|
|
19642
|
-
|
|
20354
|
+
const notesArg = args.notes;
|
|
20355
|
+
const explicitReport = typeof args.report === "boolean" ? args.report : void 0;
|
|
20356
|
+
const papiBug = looksLikePapiBug(text, notesArg);
|
|
20357
|
+
const autoRoutedUpstream = explicitReport === void 0 && papiBug;
|
|
20358
|
+
const shouldReportUpstream = explicitReport === true || autoRoutedUpstream;
|
|
20359
|
+
if (shouldReportUpstream) {
|
|
19643
20360
|
if (!adapter2.submitBugReport) {
|
|
20361
|
+
if (autoRoutedUpstream) {
|
|
20362
|
+
return errorResponse(
|
|
20363
|
+
`This looks like a PAPI bug, which belongs upstream with the PAPI maintainers \u2014 but upstream reporting needs a database adapter (pg or proxy) and this session uses the md adapter, which can't submit it. Two options: (1) switch to the hosted/pg setup to report it upstream, or (2) re-run with report=false to file it on your own project board instead.`
|
|
20364
|
+
);
|
|
20365
|
+
}
|
|
19644
20366
|
return errorResponse("Bug reports require a database adapter (pg or proxy). The md adapter does not support bug reports.");
|
|
19645
20367
|
}
|
|
20368
|
+
const type = args.type === "idea" ? "idea" : "bug";
|
|
20369
|
+
const notifyRequested = args.notify_when_fixed === true;
|
|
20370
|
+
const contactOk = args.contact_ok === true;
|
|
19646
20371
|
const diagnostics = collectDiagnostics(config2);
|
|
19647
20372
|
const notes = args.notes?.trim();
|
|
19648
20373
|
if (notes) {
|
|
@@ -19658,17 +20383,30 @@ async function handleBug(adapter2, config2, args) {
|
|
|
19658
20383
|
}
|
|
19659
20384
|
const report = await adapter2.submitBugReport({
|
|
19660
20385
|
projectId,
|
|
20386
|
+
type,
|
|
19661
20387
|
description: text,
|
|
19662
20388
|
diagnostics,
|
|
19663
|
-
status: "open"
|
|
20389
|
+
status: "open",
|
|
20390
|
+
notifyRequested,
|
|
20391
|
+
contactOk
|
|
19664
20392
|
});
|
|
20393
|
+
const kindLabel = type === "idea" ? "Idea" : "Bug report";
|
|
20394
|
+
const followUps = [];
|
|
20395
|
+
if (notifyRequested) followUps.push("you'll be notified when it's resolved");
|
|
20396
|
+
if (contactOk) followUps.push("the PAPI team may reach out");
|
|
20397
|
+
const followUpLine = followUps.length ? `
|
|
20398
|
+
|
|
20399
|
+
Noted: ${followUps.join("; ")}.` : "";
|
|
20400
|
+
const autoRouteLine = autoRoutedUpstream ? `
|
|
20401
|
+
|
|
20402
|
+
_Detected this as a PAPI bug and sent it upstream rather than adding it to your own board. If it's actually about your project, re-run with \`report=false\` to file it on your board instead._` : "";
|
|
19665
20403
|
return textResponse(
|
|
19666
|
-
|
|
20404
|
+
`**${kindLabel} submitted** \u2014 ID: \`${report.id}\`
|
|
19667
20405
|
|
|
19668
|
-
Description: ${text}
|
|
20406
|
+
${type === "idea" ? "Idea" : "Description"}: ${text}
|
|
19669
20407
|
Diagnostics collected: Node ${diagnostics.nodeVersion}, ${diagnostics.platform}/${diagnostics.arch}, adapter=${diagnostics.adapterType}
|
|
19670
20408
|
|
|
19671
|
-
This
|
|
20409
|
+
This ${type} is visible to PAPI maintainers.${followUpLine}${autoRouteLine}`
|
|
19672
20410
|
);
|
|
19673
20411
|
}
|
|
19674
20412
|
let target = adapter2;
|
|
@@ -20045,7 +20783,7 @@ async function prepareReconcile(adapter2, projectRoot) {
|
|
|
20045
20783
|
const misaligned = allTasks.filter((t) => {
|
|
20046
20784
|
const mapping = phaseStageMap.get(t.phase ?? "");
|
|
20047
20785
|
if (!mapping) return false;
|
|
20048
|
-
return mapping.stage.status === "
|
|
20786
|
+
return mapping.stage.status === "Deferred" || mapping.horizon.status === "Deferred";
|
|
20049
20787
|
});
|
|
20050
20788
|
if (misaligned.length > 0) {
|
|
20051
20789
|
lines.push("### Hierarchy Misalignment (tasks in deferred stages/horizons)");
|
|
@@ -20642,109 +21380,30 @@ Re-run build_execute complete with a production_verification field, then re-subm
|
|
|
20642
21380
|
}
|
|
20643
21381
|
const handoffRegenerated = false;
|
|
20644
21382
|
let handoffRegenPrompt;
|
|
20645
|
-
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
|
-
);
|
|
20741
|
-
}
|
|
20742
|
-
if (state.releaseSinceLastOrient) {
|
|
20743
|
-
signals.push(
|
|
20744
|
-
"Release just ran \u2014 start a fresh session before the next `plan` to keep planning context clean."
|
|
20745
|
-
);
|
|
21383
|
+
if (input.stage === "handoff-review" && input.verdict === "request-changes" && task.buildHandoff) {
|
|
21384
|
+
handoffRegenPrompt = await prepareHandoffRegen(task, input.comments, adapter2);
|
|
20746
21385
|
}
|
|
20747
|
-
|
|
21386
|
+
const stageLabel = input.stage === "handoff-review" ? "Handoff Review" : "Build Acceptance";
|
|
21387
|
+
let phaseChanges = [];
|
|
21388
|
+
if (newStatus) {
|
|
21389
|
+
try {
|
|
21390
|
+
phaseChanges = await propagatePhaseStatus(adapter2);
|
|
21391
|
+
} catch {
|
|
21392
|
+
}
|
|
21393
|
+
}
|
|
21394
|
+
return {
|
|
21395
|
+
stageLabel,
|
|
21396
|
+
taskId: input.taskId,
|
|
21397
|
+
verdict: input.verdict,
|
|
21398
|
+
comments: input.comments.trim(),
|
|
21399
|
+
newStatus,
|
|
21400
|
+
unblockedTasks,
|
|
21401
|
+
handoffRegenerated,
|
|
21402
|
+
handoffRegenPrompt,
|
|
21403
|
+
currentCycle: cycle,
|
|
21404
|
+
phaseChanges,
|
|
21405
|
+
closedDocActions
|
|
21406
|
+
};
|
|
20748
21407
|
}
|
|
20749
21408
|
|
|
20750
21409
|
// src/tools/review.ts
|
|
@@ -20893,6 +21552,11 @@ function formatReviewList(pendingBuilds) {
|
|
|
20893
21552
|
for (const t of pendingBuilds) {
|
|
20894
21553
|
lines.push(`- **${t.id}:** ${t.title}`);
|
|
20895
21554
|
lines.push(` Status: ${t.status} | Priority: ${t.priority} | Complexity: ${t.complexity}`);
|
|
21555
|
+
if (t.assigneeId || t.reviewerId) {
|
|
21556
|
+
const built = t.assigneeId ? `built by ${t.assigneeId}` : "unattributed build";
|
|
21557
|
+
const review = t.reviewerId ? `claimed by reviewer ${t.reviewerId}` : "unclaimed \u2014 `review_claim` to take it";
|
|
21558
|
+
lines.push(` ${built} \xB7 ${review}`);
|
|
21559
|
+
}
|
|
20896
21560
|
}
|
|
20897
21561
|
lines.push("");
|
|
20898
21562
|
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 +21610,10 @@ function mergeAfterAccept(config2, taskId) {
|
|
|
20946
21610
|
}
|
|
20947
21611
|
const merge = mergePullRequest(config2.projectRoot, featureBranch);
|
|
20948
21612
|
if (!merge.success) {
|
|
21613
|
+
if (isBranchMergedInto(config2.projectRoot, featureBranch, baseBranch)) {
|
|
21614
|
+
details.push(`Branch '${featureBranch}' is already merged into '${baseBranch}'.`);
|
|
21615
|
+
return { merged: true, skipped: false, message: `Already merged: \`${featureBranch}\` \u2192 \`${baseBranch}\`.`, details };
|
|
21616
|
+
}
|
|
20949
21617
|
return { merged: false, skipped: false, message: `PR merge failed: ${merge.message}`, details };
|
|
20950
21618
|
}
|
|
20951
21619
|
details.push(merge.message);
|
|
@@ -21070,12 +21738,28 @@ async function handleReviewSubmit(adapter2, config2, args) {
|
|
|
21070
21738
|
}
|
|
21071
21739
|
if (stage === "build-acceptance" && verdict === "accept") {
|
|
21072
21740
|
const reviewerConfirmed = args.reviewer_confirmed === true;
|
|
21073
|
-
if (!reviewerConfirmed && !wasReviewListSeenRecently()) {
|
|
21741
|
+
if (!reviewerConfirmed && !wasReviewListSeenRecently(void 0, callerKeyFromConfig(config2))) {
|
|
21074
21742
|
return errorResponse(
|
|
21075
21743
|
"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
21744
|
);
|
|
21077
21745
|
}
|
|
21078
21746
|
}
|
|
21747
|
+
if (stage === "build-acceptance") {
|
|
21748
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
21749
|
+
const caller = gate.callerUserId;
|
|
21750
|
+
const reviewTask = await adapter2.getTask(taskId);
|
|
21751
|
+
if (reviewTask?.reviewerId && caller && reviewTask.reviewerId !== caller && !gate.callerIsOwner) {
|
|
21752
|
+
return errorResponse(
|
|
21753
|
+
`Task "${taskId}" is claimed for review by another member (${reviewTask.reviewerId}). Only the claiming reviewer (or the project owner) can submit its verdict.`
|
|
21754
|
+
);
|
|
21755
|
+
}
|
|
21756
|
+
if (reviewTask && !reviewTask.reviewerId && caller) {
|
|
21757
|
+
try {
|
|
21758
|
+
await adapter2.updateTask(taskId, { reviewerId: caller });
|
|
21759
|
+
} catch {
|
|
21760
|
+
}
|
|
21761
|
+
}
|
|
21762
|
+
}
|
|
21079
21763
|
const tracker = new ProgressTracker("validate-args");
|
|
21080
21764
|
const handoffRegenResponse = args.handoff_regen_response;
|
|
21081
21765
|
if (handoffRegenResponse?.trim()) {
|
|
@@ -21123,6 +21807,7 @@ ${result.handoffRegenPrompt.userMessage}
|
|
|
21123
21807
|
}
|
|
21124
21808
|
let mergeNote = "";
|
|
21125
21809
|
let overlapNote = "";
|
|
21810
|
+
let mergeFailed = false;
|
|
21126
21811
|
if (stage === "build-acceptance" && verdict === "accept") {
|
|
21127
21812
|
const mergeResult = mergeAfterAccept(config2, taskId);
|
|
21128
21813
|
if (mergeResult.skipped) {
|
|
@@ -21132,14 +21817,24 @@ ${result.handoffRegenPrompt.userMessage}
|
|
|
21132
21817
|
> ${mergeResult.message}`;
|
|
21133
21818
|
}
|
|
21134
21819
|
} else if (!mergeResult.merged) {
|
|
21820
|
+
mergeFailed = true;
|
|
21821
|
+
let revertNote = "";
|
|
21822
|
+
try {
|
|
21823
|
+
await adapter2.updateTask(taskId, { status: "In Review" });
|
|
21824
|
+
revertNote = ` Task rolled back to **In Review** \u2014 re-run \`review_submit ${taskId} build-acceptance accept\` once the merge succeeds.`;
|
|
21825
|
+
} catch (err) {
|
|
21826
|
+
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.`;
|
|
21827
|
+
}
|
|
21135
21828
|
mergeNote = `
|
|
21136
21829
|
|
|
21137
|
-
\u26A0\uFE0F **PR merge failed
|
|
21138
|
-
|
|
21139
|
-
${mergeResult.message}
|
|
21830
|
+
\u26A0\uFE0F **PR merge failed \u2014 task NOT marked Done.**${revertNote}
|
|
21140
21831
|
|
|
21141
|
-
|
|
21832
|
+
${mergeResult.message}`;
|
|
21142
21833
|
} else {
|
|
21834
|
+
try {
|
|
21835
|
+
await adapter2.updateTask(taskId, { mergedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
21836
|
+
} catch {
|
|
21837
|
+
}
|
|
21143
21838
|
const detailLines = mergeResult.details.map((l) => `> ${l}`).join("\n");
|
|
21144
21839
|
mergeNote = `
|
|
21145
21840
|
|
|
@@ -21159,7 +21854,7 @@ ${overlap}`;
|
|
|
21159
21854
|
}
|
|
21160
21855
|
let autoReleaseNote = "";
|
|
21161
21856
|
let batchSummaryNote = "";
|
|
21162
|
-
if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0) {
|
|
21857
|
+
if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0 && !mergeFailed) {
|
|
21163
21858
|
try {
|
|
21164
21859
|
const allTasks = await adapter2.queryBoard();
|
|
21165
21860
|
const cycleTasks = allTasks.filter((t) => t.cycle === result.currentCycle);
|
|
@@ -21252,9 +21947,28 @@ Resolve the issue above, then run \`release\` manually.`;
|
|
|
21252
21947
|
}
|
|
21253
21948
|
}
|
|
21254
21949
|
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
|
-
|
|
21950
|
+
let autoReviewNote = "";
|
|
21951
|
+
if (autoReview) {
|
|
21952
|
+
const findingLines = autoReview.findings.slice(0, 10).map((f) => {
|
|
21953
|
+
const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
|
|
21954
|
+
return ` - [${f.severity}]${loc} ${f.message}`;
|
|
21955
|
+
}).join("\n");
|
|
21956
|
+
const more = autoReview.findings.length > 10 ? `
|
|
21957
|
+
\u2026and ${autoReview.findings.length - 10} more` : "";
|
|
21958
|
+
autoReviewNote = `
|
|
21959
|
+
|
|
21960
|
+
**Quality Gate (auto-review): ${autoReview.verdict}** \u2014 ${autoReview.summary}` + (autoReview.findings.length > 0 ? `
|
|
21961
|
+
${findingLines}${more}` : "");
|
|
21962
|
+
if (stage === "build-acceptance" && verdict === "accept" && autoReview.verdict !== "pass") {
|
|
21963
|
+
autoReviewNote += `
|
|
21964
|
+
|
|
21965
|
+
\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.`;
|
|
21966
|
+
}
|
|
21967
|
+
} else if (stage === "build-acceptance" && verdict === "accept") {
|
|
21968
|
+
autoReviewNote = `
|
|
21256
21969
|
|
|
21257
|
-
**
|
|
21970
|
+
**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.`;
|
|
21971
|
+
}
|
|
21258
21972
|
let nextStepNote = "";
|
|
21259
21973
|
if (!autoReleaseNote && stage === "build-acceptance") {
|
|
21260
21974
|
if (verdict === "accept") {
|
|
@@ -21290,6 +22004,66 @@ ${statusNote}${autoReviewNote}${unblockNote}${docClosureNote}${regenNote}${merge
|
|
|
21290
22004
|
}));
|
|
21291
22005
|
}
|
|
21292
22006
|
}
|
|
22007
|
+
var reviewClaimTool = {
|
|
22008
|
+
name: "review_claim",
|
|
22009
|
+
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.",
|
|
22010
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
22011
|
+
inputSchema: {
|
|
22012
|
+
type: "object",
|
|
22013
|
+
properties: {
|
|
22014
|
+
task_id: { type: "string", description: 'The In Review task to claim for review, e.g. "task-2072".' }
|
|
22015
|
+
},
|
|
22016
|
+
required: ["task_id"]
|
|
22017
|
+
}
|
|
22018
|
+
};
|
|
22019
|
+
async function resolveReviewerIdentity(adapter2, config2) {
|
|
22020
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
22021
|
+
const callerUserId = gate.callerUserId;
|
|
22022
|
+
if (!callerUserId) {
|
|
22023
|
+
const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
|
|
22024
|
+
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." };
|
|
22025
|
+
}
|
|
22026
|
+
if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
|
|
22027
|
+
try {
|
|
22028
|
+
const members = await adapter2.listContributors();
|
|
22029
|
+
if (!members.some((m) => m.userId === callerUserId)) {
|
|
22030
|
+
return { error: "Only the project owner or an active contributor can claim reviews on this project." };
|
|
22031
|
+
}
|
|
22032
|
+
} catch (err) {
|
|
22033
|
+
return { error: `Could not verify membership: ${err instanceof Error ? err.message : String(err)}` };
|
|
22034
|
+
}
|
|
22035
|
+
}
|
|
22036
|
+
return { callerUserId, callerIsOwner: gate.callerIsOwner };
|
|
22037
|
+
}
|
|
22038
|
+
async function handleReviewClaim(adapter2, config2, args) {
|
|
22039
|
+
const taskId = typeof args.task_id === "string" ? args.task_id.trim() : "";
|
|
22040
|
+
if (!taskId) return errorResponse('A task_id is required. Example: review_claim task_id="task-2072"');
|
|
22041
|
+
if (typeof adapter2.claimReview !== "function") {
|
|
22042
|
+
return errorResponse("The shared review queue is not available on this adapter.");
|
|
22043
|
+
}
|
|
22044
|
+
const identity = await resolveReviewerIdentity(adapter2, config2);
|
|
22045
|
+
if ("error" in identity) return errorResponse(identity.error);
|
|
22046
|
+
const me = identity.callerUserId;
|
|
22047
|
+
const claimed = await adapter2.claimReview(taskId, me);
|
|
22048
|
+
if (!claimed) {
|
|
22049
|
+
const task = await adapter2.getTask(taskId);
|
|
22050
|
+
if (!task) return errorResponse(`Task "${taskId}" not found on the board.`);
|
|
22051
|
+
if (task.status !== "In Review") {
|
|
22052
|
+
return errorResponse(`Task "${taskId}" is ${task.status}, not In Review \u2014 there is no pending review to claim.`);
|
|
22053
|
+
}
|
|
22054
|
+
if (task.reviewerId && task.reviewerId !== me) {
|
|
22055
|
+
return errorResponse(`Task "${taskId}" is already being reviewed by another member (${task.reviewerId}).`);
|
|
22056
|
+
}
|
|
22057
|
+
if (task.reviewerId === me) {
|
|
22058
|
+
return textResponse(`You have already claimed the review for **${taskId}** \u2014 run \`review_submit\` to record your verdict.`);
|
|
22059
|
+
}
|
|
22060
|
+
return errorResponse(`Could not claim the review for "${taskId}".`);
|
|
22061
|
+
}
|
|
22062
|
+
const builtBy = claimed.assigneeId ? ` (built by ${claimed.assigneeId})` : "";
|
|
22063
|
+
return textResponse(
|
|
22064
|
+
`\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.`
|
|
22065
|
+
);
|
|
22066
|
+
}
|
|
21293
22067
|
|
|
21294
22068
|
// src/tools/init.ts
|
|
21295
22069
|
import { randomUUID as randomUUID15 } from "crypto";
|
|
@@ -22023,7 +22797,7 @@ ${lines.join("\n")}`;
|
|
|
22023
22797
|
|
|
22024
22798
|
// src/lib/version-handshake.ts
|
|
22025
22799
|
var MIN_PROXY_VERSION = "1.0.0";
|
|
22026
|
-
var SERVER_VERSION = "0.7.
|
|
22800
|
+
var SERVER_VERSION = "0.7.36";
|
|
22027
22801
|
function meetsMinVersion(actual, required) {
|
|
22028
22802
|
const actualParts = actual.split(".").map((n) => parseInt(n, 10));
|
|
22029
22803
|
const requiredParts = required.split(".").map((n) => parseInt(n, 10));
|
|
@@ -22405,13 +23179,29 @@ function normalizeDocPath(rawPath, projectRoot) {
|
|
|
22405
23179
|
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
23180
|
};
|
|
22407
23181
|
}
|
|
23182
|
+
function docRegisterSoftFail(adapterType, lastStep, error, hint) {
|
|
23183
|
+
const payload = { tool: "doc_register", adapter: adapterType, lastStep, error, hint };
|
|
23184
|
+
return textResponse(
|
|
23185
|
+
`\u26A0\uFE0F doc_register skipped (non-blocking) at step "${lastStep}" \u2014 your build/plan/review flow can continue safely; document registration is advisory.
|
|
23186
|
+
|
|
23187
|
+
Diagnostic JSON:
|
|
23188
|
+
${JSON.stringify(payload, null, 2)}`
|
|
23189
|
+
);
|
|
23190
|
+
}
|
|
22408
23191
|
async function handleDocRegister(adapter2, args, config2) {
|
|
23192
|
+
const adapterType = config2?.adapterType ?? "unknown";
|
|
23193
|
+
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
23194
|
if (!adapter2.registerDoc) {
|
|
22410
|
-
return
|
|
23195
|
+
return docRegisterSoftFail(
|
|
23196
|
+
adapterType,
|
|
23197
|
+
"adapter-capability-check",
|
|
23198
|
+
"Doc registry not available \u2014 requires the pg/proxy adapter.",
|
|
23199
|
+
"On the md adapter doc_register is a no-op. Continue without it \u2014 nothing is blocked."
|
|
23200
|
+
);
|
|
22411
23201
|
}
|
|
22412
23202
|
const normalized = normalizeDocPath(args.path, config2?.projectRoot);
|
|
22413
23203
|
if ("error" in normalized) {
|
|
22414
|
-
return
|
|
23204
|
+
return docRegisterSoftFail(adapterType, "path-normalization", normalized.error, continueHint);
|
|
22415
23205
|
}
|
|
22416
23206
|
const path7 = normalized.path;
|
|
22417
23207
|
const title = args.title;
|
|
@@ -22424,39 +23214,49 @@ async function handleDocRegister(adapter2, args, config2) {
|
|
|
22424
23214
|
const supersededByPath = args.superseded_by_path;
|
|
22425
23215
|
const visibility = resolveDocVisibility(args.visibility, path7);
|
|
22426
23216
|
if (!path7 || !title || !type || !summary || !cycle) {
|
|
22427
|
-
return
|
|
23217
|
+
return docRegisterSoftFail(
|
|
23218
|
+
adapterType,
|
|
23219
|
+
"field-validation",
|
|
23220
|
+
"Required fields: path, title, type, summary, cycle.",
|
|
23221
|
+
continueHint
|
|
23222
|
+
);
|
|
22428
23223
|
}
|
|
22429
|
-
|
|
22430
|
-
|
|
22431
|
-
|
|
22432
|
-
|
|
22433
|
-
|
|
22434
|
-
|
|
23224
|
+
try {
|
|
23225
|
+
let supersededBy;
|
|
23226
|
+
if (supersededByPath) {
|
|
23227
|
+
const existing = await adapter2.getDoc?.(supersededByPath);
|
|
23228
|
+
if (existing) {
|
|
23229
|
+
supersededBy = existing.id;
|
|
23230
|
+
await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
|
|
23231
|
+
}
|
|
22435
23232
|
}
|
|
22436
|
-
|
|
22437
|
-
|
|
22438
|
-
|
|
22439
|
-
|
|
22440
|
-
|
|
22441
|
-
|
|
22442
|
-
|
|
22443
|
-
|
|
22444
|
-
|
|
22445
|
-
|
|
22446
|
-
|
|
22447
|
-
|
|
22448
|
-
|
|
22449
|
-
|
|
22450
|
-
|
|
22451
|
-
|
|
22452
|
-
`**Registered:** ${entry.title}
|
|
23233
|
+
const entry = await adapter2.registerDoc({
|
|
23234
|
+
title,
|
|
23235
|
+
type,
|
|
23236
|
+
path: path7,
|
|
23237
|
+
status,
|
|
23238
|
+
summary,
|
|
23239
|
+
tags,
|
|
23240
|
+
cycleCreated: cycle,
|
|
23241
|
+
cycleUpdated: cycle,
|
|
23242
|
+
supersededBy,
|
|
23243
|
+
actions,
|
|
23244
|
+
visibility
|
|
23245
|
+
});
|
|
23246
|
+
const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
|
|
23247
|
+
return textResponse(
|
|
23248
|
+
`**Registered:** ${entry.title}
|
|
22453
23249
|
- **Path:** ${entry.path}
|
|
22454
23250
|
- **Type:** ${entry.type} | **Status:** ${entry.status}
|
|
22455
23251
|
- **Visibility:** ${visibilityLabel}
|
|
22456
23252
|
- **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
|
|
22457
23253
|
- **Actions:** ${actions?.length ?? 0} items
|
|
22458
23254
|
- **ID:** ${entry.id}`
|
|
22459
|
-
|
|
23255
|
+
);
|
|
23256
|
+
} catch (err) {
|
|
23257
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
23258
|
+
return docRegisterSoftFail(adapterType, "registry-write", message, continueHint);
|
|
23259
|
+
}
|
|
22460
23260
|
}
|
|
22461
23261
|
async function handleDocSearch(adapter2, args, config2) {
|
|
22462
23262
|
if (!adapter2.searchDocs) {
|
|
@@ -22807,11 +23607,20 @@ var orientTool = {
|
|
|
22807
23607
|
deep_housekeeping: {
|
|
22808
23608
|
type: "boolean",
|
|
22809
23609
|
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."
|
|
23610
|
+
},
|
|
23611
|
+
full: {
|
|
23612
|
+
type: "boolean",
|
|
23613
|
+
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
23614
|
}
|
|
22811
23615
|
},
|
|
22812
23616
|
required: []
|
|
22813
23617
|
}
|
|
22814
23618
|
};
|
|
23619
|
+
var papiTool = {
|
|
23620
|
+
...orientTool,
|
|
23621
|
+
name: "papi",
|
|
23622
|
+
description: 'Say "papi" to check in with Papi \u2014 an alias for `orient`. Run this FIRST at session start: it returns your cycle number, task counts, in-progress/in-review work, strategy-review cadence, a velocity snapshot, and the recommended next action. Identical to `orient` (same inputs, same output); use whichever name you prefer. Read-only.'
|
|
23623
|
+
};
|
|
22815
23624
|
function countStalledP1(warnings) {
|
|
22816
23625
|
const stallLine = warnings.find((w) => w.includes("P1 tasks stalled"));
|
|
22817
23626
|
if (!stallLine) return 0;
|
|
@@ -22837,7 +23646,7 @@ function formatSynthesisParagraph(opts) {
|
|
|
22837
23646
|
parts.push(`Next: ${cleanMode}`);
|
|
22838
23647
|
return parts.join(" ");
|
|
22839
23648
|
}
|
|
22840
|
-
function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName) {
|
|
23649
|
+
function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName, teamSummary) {
|
|
22841
23650
|
const lines = [];
|
|
22842
23651
|
const cycleIsComplete = health.latestCycleStatus === "complete";
|
|
22843
23652
|
const tagSuffix = latestTag ? ` \u2014 ${latestTag}` : "";
|
|
@@ -22904,6 +23713,10 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
|
|
|
22904
23713
|
lines.push("## Board");
|
|
22905
23714
|
lines.push(health.boardSummary);
|
|
22906
23715
|
lines.push("");
|
|
23716
|
+
if (teamSummary) {
|
|
23717
|
+
lines.push(teamSummary);
|
|
23718
|
+
lines.push("");
|
|
23719
|
+
}
|
|
22907
23720
|
if (subAgents.length > 0) {
|
|
22908
23721
|
lines.push(`**Sub-agents (${subAgents.length}):** ` + subAgents.map((a) => {
|
|
22909
23722
|
const desc = a.description ? ` (${a.description.slice(0, 60)})` : "";
|
|
@@ -23108,9 +23921,56 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
|
|
|
23108
23921
|
}
|
|
23109
23922
|
return { alertsNote, unactionedIssuesNote };
|
|
23110
23923
|
}
|
|
23924
|
+
async function computeTeamSummary(adapter2) {
|
|
23925
|
+
if (typeof adapter2.listContributors !== "function") return void 0;
|
|
23926
|
+
let members;
|
|
23927
|
+
try {
|
|
23928
|
+
members = (await adapter2.listContributors()).length;
|
|
23929
|
+
} catch {
|
|
23930
|
+
return void 0;
|
|
23931
|
+
}
|
|
23932
|
+
if (members <= 1) return void 0;
|
|
23933
|
+
let tasks;
|
|
23934
|
+
try {
|
|
23935
|
+
tasks = await adapter2.queryBoard({ compact: true });
|
|
23936
|
+
} catch {
|
|
23937
|
+
return void 0;
|
|
23938
|
+
}
|
|
23939
|
+
const isOpen = (t) => t.status !== "Done" && t.status !== "Cancelled" && t.status !== "Archived";
|
|
23940
|
+
const pool = tasks.filter((t) => isOpen(t) && !t.assigneeId && t.cycle == null).length;
|
|
23941
|
+
const inFlight = tasks.filter((t) => t.status === "In Progress").length;
|
|
23942
|
+
const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
|
|
23943
|
+
return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
|
|
23944
|
+
}
|
|
23945
|
+
async function computeReleaseHistory(adapter2) {
|
|
23946
|
+
if (typeof adapter2.listContributors !== "function") return void 0;
|
|
23947
|
+
let contributors;
|
|
23948
|
+
try {
|
|
23949
|
+
contributors = await adapter2.listContributors();
|
|
23950
|
+
} catch {
|
|
23951
|
+
return void 0;
|
|
23952
|
+
}
|
|
23953
|
+
if (contributors.length <= 1) return void 0;
|
|
23954
|
+
let cycles;
|
|
23955
|
+
try {
|
|
23956
|
+
cycles = await adapter2.readCycles();
|
|
23957
|
+
} catch {
|
|
23958
|
+
return void 0;
|
|
23959
|
+
}
|
|
23960
|
+
const nameOf = new Map(
|
|
23961
|
+
contributors.map((c) => [c.userId, c.displayName || c.email || c.userId.slice(0, 8)])
|
|
23962
|
+
);
|
|
23963
|
+
const released = cycles.filter((c) => c.status === "complete").sort((a, b2) => b2.number - a.number).slice(0, 5).map((c) => {
|
|
23964
|
+
const who = c.userId ? nameOf.get(c.userId) ?? c.userId.slice(0, 8) : null;
|
|
23965
|
+
return `Cycle ${c.number}${who ? ` (${who})` : ""}`;
|
|
23966
|
+
});
|
|
23967
|
+
if (released.length === 0) return void 0;
|
|
23968
|
+
return `**Recent releases:** ${released.join(" \xB7 ")}`;
|
|
23969
|
+
}
|
|
23111
23970
|
async function handleOrient(adapter2, config2, args = {}) {
|
|
23112
23971
|
const environment = normaliseEnvironment(args.environment);
|
|
23113
23972
|
const deepHousekeeping = args.deep_housekeeping === true;
|
|
23973
|
+
const fullEnrichment = args.full === true || deepHousekeeping;
|
|
23114
23974
|
const tracker = new ProgressTracker("start");
|
|
23115
23975
|
try {
|
|
23116
23976
|
tracker.mark("propagate-phase-status");
|
|
@@ -23157,6 +24017,7 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23157
24017
|
ownerActionsOutcome,
|
|
23158
24018
|
ownerActionsNudgedOutcome,
|
|
23159
24019
|
ownerActionsBlockingOutcome,
|
|
24020
|
+
reviewChangesOutcome,
|
|
23160
24021
|
ttfvOutcome,
|
|
23161
24022
|
latestTagOutcome,
|
|
23162
24023
|
versionDriftOutcome,
|
|
@@ -23247,6 +24108,37 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23247
24108
|
return void 0;
|
|
23248
24109
|
}
|
|
23249
24110
|
}),
|
|
24111
|
+
// task-2105 (C296): review-verdict notification. When a build was bounced
|
|
24112
|
+
// back (request-changes/reject → status In Progress) and not yet resubmitted,
|
|
24113
|
+
// surface it in orient so the builder gets a signal — submitReview itself has
|
|
24114
|
+
// no notification channel. Derived from existing data (no new schema): latest
|
|
24115
|
+
// review per task + current status. Within-project only (board visibility
|
|
24116
|
+
// already scopes the caller); per-assignee targeting is a future refinement.
|
|
24117
|
+
tracked("review-changes-requested", async () => {
|
|
24118
|
+
if (!adapter2.getRecentReviews || !adapter2.getTask) return void 0;
|
|
24119
|
+
try {
|
|
24120
|
+
const reviews = await adapter2.getRecentReviews(100);
|
|
24121
|
+
const latestByTask = /* @__PURE__ */ new Map();
|
|
24122
|
+
for (const r of reviews) {
|
|
24123
|
+
if (!latestByTask.has(r.taskId)) latestByTask.set(r.taskId, { verdict: r.verdict, comments: r.comments });
|
|
24124
|
+
}
|
|
24125
|
+
const bounced = [];
|
|
24126
|
+
for (const [taskId, rev] of latestByTask) {
|
|
24127
|
+
if (rev.verdict !== "request-changes" && rev.verdict !== "reject") continue;
|
|
24128
|
+
const task = await adapter2.getTask(taskId);
|
|
24129
|
+
if (task && task.status === "In Progress") {
|
|
24130
|
+
bounced.push({ id: task.displayId ?? taskId, comment: rev.comments ?? "" });
|
|
24131
|
+
}
|
|
24132
|
+
}
|
|
24133
|
+
if (bounced.length === 0) return void 0;
|
|
24134
|
+
const list = bounced.slice(0, 5).map((b2) => b2.id).join(", ");
|
|
24135
|
+
const c = bounced[0].comment.trim();
|
|
24136
|
+
const snippet = c ? ` \u2014 ${bounced[0].id}: "${c.length > 80 ? `${c.slice(0, 80)}\u2026` : c}"` : "";
|
|
24137
|
+
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.`;
|
|
24138
|
+
} catch {
|
|
24139
|
+
return void 0;
|
|
24140
|
+
}
|
|
24141
|
+
}),
|
|
23250
24142
|
// Time-to-first-value: setup/plan milestones (only for early projects).
|
|
23251
24143
|
// PERF: hasToolMilestone is a cheap EXISTS lookup; avoids 5000-row pull.
|
|
23252
24144
|
tracked("ttfv", async () => {
|
|
@@ -23265,11 +24157,16 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23265
24157
|
return `
|
|
23266
24158
|
**Time to first plan:** ${deltaMinutes} minutes (setup \u2192 first plan)`;
|
|
23267
24159
|
}),
|
|
23268
|
-
// Latest git tag + npm version drift (both exec calls with own timeouts)
|
|
24160
|
+
// Latest git tag + npm version drift (both exec calls with own timeouts).
|
|
24161
|
+
// task-2172: git-tag stays (the latest tag is part of the core summary);
|
|
24162
|
+
// version-drift is enrichment, gated behind `full`/deep_housekeeping.
|
|
23269
24163
|
tracked("git-tag", () => getLatestGitTag(config2.projectRoot)),
|
|
23270
|
-
tracked("npm-version-drift", () => checkNpmVersionDrift()),
|
|
23271
|
-
// Research Signals — research docs with pending actions since last strategy review
|
|
24164
|
+
tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
|
|
24165
|
+
// Research Signals — research docs with pending actions since last strategy review.
|
|
24166
|
+
// task-2172: heavy (doc search + AD cross-reference) and rarely actioned
|
|
24167
|
+
// mid-session — gated behind `full`/deep_housekeeping to keep the default lean.
|
|
23272
24168
|
tracked("research-signals", async () => {
|
|
24169
|
+
if (!fullEnrichment) return "";
|
|
23273
24170
|
if (!adapter2.searchDocs) return "";
|
|
23274
24171
|
const cycleHealth = await adapter2.getCycleHealth();
|
|
23275
24172
|
const lastReviewCycle = Math.max(0, cycleHealth.totalCycles - cycleHealth.cyclesSinceLastStrategyReview);
|
|
@@ -23370,8 +24267,9 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23370
24267
|
}),
|
|
23371
24268
|
// Session guidance — proactive nudges (doc_register, context bloat, mode switch)
|
|
23372
24269
|
tracked("session-guidance", async () => {
|
|
23373
|
-
const
|
|
23374
|
-
|
|
24270
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
24271
|
+
const signals = await buildSessionGuidance(callerKey);
|
|
24272
|
+
markOrient(callerKey);
|
|
23375
24273
|
if (signals.length === 0) return "";
|
|
23376
24274
|
const lines = ["\n\n## Session Guidance"];
|
|
23377
24275
|
for (const s of signals) lines.push(`- ${s}`);
|
|
@@ -23468,6 +24366,8 @@ async function handleOrient(adapter2, config2, args = {}) {
|
|
|
23468
24366
|
if (ownerActionsNudgedWarning) buildResult.warnings.push(ownerActionsNudgedWarning);
|
|
23469
24367
|
const ownerActionsBlockingWarning = ownerActionsBlockingOutcome.status === "fulfilled" ? ownerActionsBlockingOutcome.value : void 0;
|
|
23470
24368
|
if (ownerActionsBlockingWarning) buildResult.warnings.push(ownerActionsBlockingWarning);
|
|
24369
|
+
const reviewChangesWarning = reviewChangesOutcome.status === "fulfilled" ? reviewChangesOutcome.value : void 0;
|
|
24370
|
+
if (reviewChangesWarning) buildResult.warnings.push(reviewChangesWarning);
|
|
23471
24371
|
try {
|
|
23472
24372
|
const verifyWarnings = await verifyProject(adapter2);
|
|
23473
24373
|
for (const w of verifyWarnings) buildResult.warnings.push(w);
|
|
@@ -23531,8 +24431,13 @@ ${section}`;
|
|
|
23531
24431
|
}
|
|
23532
24432
|
tracker.mark("format-summary");
|
|
23533
24433
|
const subAgents = await listAgents(config2.projectRoot);
|
|
23534
|
-
const
|
|
23535
|
-
|
|
24434
|
+
const [teamSummaryLine, releaseHistoryLine] = await Promise.all([
|
|
24435
|
+
tracked("team-summary", () => computeTeamSummary(adapter2))().catch(() => void 0),
|
|
24436
|
+
tracked("release-history", () => computeReleaseHistory(adapter2))().catch(() => void 0)
|
|
24437
|
+
]);
|
|
24438
|
+
const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
|
|
24439
|
+
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`).*";
|
|
24440
|
+
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
24441
|
} catch (err) {
|
|
23537
24442
|
const message = err instanceof Error ? err.message : String(err);
|
|
23538
24443
|
const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
|
|
@@ -23806,6 +24711,18 @@ async function assembleZoomOutContext(adapter2, cycleNumber, projectRoot) {
|
|
|
23806
24711
|
const lines = [];
|
|
23807
24712
|
for (const [adId, adEvents] of byDecision) {
|
|
23808
24713
|
lines.push(`**${adId}:** ${adEvents.length} events \u2014 ${adEvents.map((e) => `${e.eventType} (S${e.cycle})`).join(", ")}`);
|
|
24714
|
+
for (const e of adEvents) {
|
|
24715
|
+
const parts = [];
|
|
24716
|
+
if (e.evidenceRef) parts.push(`evidence: ${e.evidenceRef}`);
|
|
24717
|
+
if (e.metricDelta?.metric) {
|
|
24718
|
+
const d = e.metricDelta;
|
|
24719
|
+
const movement = d.before !== void 0 && d.after !== void 0 ? `${d.before}\u2192${d.after}` : d.delta !== void 0 ? `\u0394${d.delta}` : "";
|
|
24720
|
+
parts.push(`metric: ${d.metric}${movement ? ` (${movement})` : ""}`);
|
|
24721
|
+
}
|
|
24722
|
+
if (parts.length > 0) {
|
|
24723
|
+
lines.push(` - ${e.eventType} S${e.cycle}: ${parts.join("; ")}`);
|
|
24724
|
+
}
|
|
24725
|
+
}
|
|
23809
24726
|
}
|
|
23810
24727
|
adLifecycleText = lines.join("\n");
|
|
23811
24728
|
}
|
|
@@ -24136,8 +25053,7 @@ Check that the project IDs are correct and exist in the same Supabase instance.`
|
|
|
24136
25053
|
}
|
|
24137
25054
|
|
|
24138
25055
|
// src/tools/handoff.ts
|
|
24139
|
-
var
|
|
24140
|
-
var lastPrepareContextBytes2;
|
|
25056
|
+
var handoffPrepareCache = new PerCallerCache();
|
|
24141
25057
|
var handoffGenerateTool = {
|
|
24142
25058
|
name: "handoff_generate",
|
|
24143
25059
|
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 +25082,10 @@ var handoffGenerateTool = {
|
|
|
24166
25082
|
cycle_number: {
|
|
24167
25083
|
type: "number",
|
|
24168
25084
|
description: 'The cycle number returned from prepare phase (mode "apply" only).'
|
|
25085
|
+
},
|
|
25086
|
+
force: {
|
|
25087
|
+
type: "boolean",
|
|
25088
|
+
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
25089
|
}
|
|
24170
25090
|
},
|
|
24171
25091
|
required: []
|
|
@@ -24173,6 +25093,7 @@ var handoffGenerateTool = {
|
|
|
24173
25093
|
};
|
|
24174
25094
|
async function handleHandoffGenerate(adapter2, config2, args) {
|
|
24175
25095
|
const toolMode = args.mode;
|
|
25096
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
24176
25097
|
try {
|
|
24177
25098
|
if (toolMode === "apply") {
|
|
24178
25099
|
const resolved = await resolveLlmResponse(
|
|
@@ -24181,20 +25102,20 @@ async function handleHandoffGenerate(adapter2, config2, args) {
|
|
|
24181
25102
|
);
|
|
24182
25103
|
if (!resolved.ok) return errorResponse(resolved.error);
|
|
24183
25104
|
const llmResponse = resolved.llmResponse;
|
|
24184
|
-
const
|
|
25105
|
+
const prep = handoffPrepareCache.take(callerKey);
|
|
25106
|
+
const expectedCycleNumber = prep?.cycleNumber;
|
|
25107
|
+
const contextBytes = prep?.contextBytes;
|
|
25108
|
+
const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : expectedCycleNumber;
|
|
24185
25109
|
if (cycleNumber === void 0) {
|
|
24186
25110
|
return errorResponse('cycle_number is required for mode "apply". Pass the cycle number from the prepare phase.');
|
|
24187
25111
|
}
|
|
24188
|
-
const
|
|
24189
|
-
const contextBytes = lastPrepareContextBytes2;
|
|
24190
|
-
lastPrepareCycleNumber2 = void 0;
|
|
24191
|
-
lastPrepareContextBytes2 = void 0;
|
|
25112
|
+
const force = args.force === true || prep?.force === true;
|
|
24192
25113
|
if (expectedCycleNumber !== void 0 && cycleNumber !== expectedCycleNumber) {
|
|
24193
25114
|
return errorResponse(
|
|
24194
25115
|
`cycle_number mismatch: prepare phase returned cycle ${expectedCycleNumber} but apply received ${cycleNumber}.`
|
|
24195
25116
|
);
|
|
24196
25117
|
}
|
|
24197
|
-
const result = await applyHandoffs(adapter2, llmResponse, cycleNumber);
|
|
25118
|
+
const result = await applyHandoffs(adapter2, llmResponse, cycleNumber, force);
|
|
24198
25119
|
const lines = [];
|
|
24199
25120
|
lines.push(`**Handoff Generation \u2014 Cycle ${result.cycleNumber}**`);
|
|
24200
25121
|
lines.push(`${result.handoffsWritten} handoff(s) written: ${result.taskIds.join(", ")}`);
|
|
@@ -24209,9 +25130,13 @@ async function handleHandoffGenerate(adapter2, config2, args) {
|
|
|
24209
25130
|
}
|
|
24210
25131
|
{
|
|
24211
25132
|
const taskIds = Array.isArray(args.task_ids) ? args.task_ids.filter((id) => typeof id === "string") : void 0;
|
|
24212
|
-
const
|
|
24213
|
-
|
|
24214
|
-
|
|
25133
|
+
const force = args.force === true;
|
|
25134
|
+
const result = await prepareHandoffs(adapter2, config2, taskIds, force);
|
|
25135
|
+
handoffPrepareCache.set(callerKey, {
|
|
25136
|
+
cycleNumber: result.cycleNumber,
|
|
25137
|
+
contextBytes: result.contextBytes,
|
|
25138
|
+
force
|
|
25139
|
+
});
|
|
24215
25140
|
return textResponse(
|
|
24216
25141
|
`## PAPI Handoff Generation \u2014 Prepare Phase (Cycle ${result.cycleNumber})
|
|
24217
25142
|
|
|
@@ -24617,6 +25542,60 @@ async function handleDiscoveredIssueResolve(adapter2, args) {
|
|
|
24617
25542
|
|
|
24618
25543
|
// src/tools/project.ts
|
|
24619
25544
|
import path6 from "path";
|
|
25545
|
+
|
|
25546
|
+
// src/services/entitlements.ts
|
|
25547
|
+
var FREE_PROJECT_CAP = 3;
|
|
25548
|
+
var PAID_TIERS = /* @__PURE__ */ new Set(["pro", "team"]);
|
|
25549
|
+
var PRICING_URL = "https://getpapi.ai/pricing";
|
|
25550
|
+
async function resolveTier(adapter2) {
|
|
25551
|
+
if (typeof adapter2.getMeteredUsage !== "function") return null;
|
|
25552
|
+
try {
|
|
25553
|
+
const usage = await adapter2.getMeteredUsage();
|
|
25554
|
+
return usage?.tier ?? null;
|
|
25555
|
+
} catch {
|
|
25556
|
+
return null;
|
|
25557
|
+
}
|
|
25558
|
+
}
|
|
25559
|
+
function isPaidTier(tier) {
|
|
25560
|
+
return tier !== null && PAID_TIERS.has(tier);
|
|
25561
|
+
}
|
|
25562
|
+
async function enforceProjectCap(adapter2, target) {
|
|
25563
|
+
const tier = await resolveTier(adapter2);
|
|
25564
|
+
if (tier === null || isPaidTier(tier)) return null;
|
|
25565
|
+
if (typeof adapter2.listUserProjects !== "function") return null;
|
|
25566
|
+
let projects;
|
|
25567
|
+
try {
|
|
25568
|
+
projects = await adapter2.listUserProjects();
|
|
25569
|
+
} catch {
|
|
25570
|
+
return null;
|
|
25571
|
+
}
|
|
25572
|
+
const matchesExisting = projects.some(
|
|
25573
|
+
(p) => target.papiDir && p.papi_dir && p.papi_dir === target.papiDir || target.name && p.name && p.name.trim().toLowerCase() === target.name.trim().toLowerCase()
|
|
25574
|
+
);
|
|
25575
|
+
if (matchesExisting) return null;
|
|
25576
|
+
if (projects.length >= FREE_PROJECT_CAP) return projectCapMessage(projects.length);
|
|
25577
|
+
return null;
|
|
25578
|
+
}
|
|
25579
|
+
async function enforceContributorGate(adapter2) {
|
|
25580
|
+
const tier = await resolveTier(adapter2);
|
|
25581
|
+
if (tier === null) return null;
|
|
25582
|
+
if (tier === "team") return null;
|
|
25583
|
+
return contributorTeamMessage(tier);
|
|
25584
|
+
}
|
|
25585
|
+
function projectCapMessage(currentCount) {
|
|
25586
|
+
return `**You're on the Free plan (${currentCount} of ${FREE_PROJECT_CAP} projects).**
|
|
25587
|
+
|
|
25588
|
+
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}
|
|
25589
|
+
|
|
25590
|
+
Your existing projects are untouched, and you can keep working in any of them.`;
|
|
25591
|
+
}
|
|
25592
|
+
function contributorTeamMessage(tier) {
|
|
25593
|
+
return `**Adding people to a project is a Team feature.**
|
|
25594
|
+
|
|
25595
|
+
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}`;
|
|
25596
|
+
}
|
|
25597
|
+
|
|
25598
|
+
// src/tools/project.ts
|
|
24620
25599
|
function workspacePapiDir(config2) {
|
|
24621
25600
|
if (!hasLocalWorkspace()) return void 0;
|
|
24622
25601
|
return config2.papiDir;
|
|
@@ -24649,6 +25628,8 @@ async function handleProjectCreate(adapter2, config2, args) {
|
|
|
24649
25628
|
if (papiDir) name = path6.basename(config2.projectRoot) || "My Project";
|
|
24650
25629
|
else return errorResponse("name is required \u2014 there is no local workspace to derive it from on the remote transport.");
|
|
24651
25630
|
}
|
|
25631
|
+
const capDenied = await enforceProjectCap(adapter2, { papiDir, name });
|
|
25632
|
+
if (capDenied) return errorResponse(capDenied);
|
|
24652
25633
|
const result = await adapter2.createUserProject({
|
|
24653
25634
|
name,
|
|
24654
25635
|
papiDir,
|
|
@@ -24790,6 +25771,8 @@ function requireEmail(args) {
|
|
|
24790
25771
|
async function handleContributorAdd(adapter2, config2, args) {
|
|
24791
25772
|
const denied = await denyUnlessOwner(adapter2, config2);
|
|
24792
25773
|
if (denied) return errorResponse(denied);
|
|
25774
|
+
const tierDenied = await enforceContributorGate(adapter2);
|
|
25775
|
+
if (tierDenied) return errorResponse(tierDenied);
|
|
24793
25776
|
const email = requireEmail(args);
|
|
24794
25777
|
if (!email) return errorResponse('A valid email is required. Example: contributor_add email="wes@example.com"');
|
|
24795
25778
|
try {
|
|
@@ -24843,6 +25826,155 @@ async function handleContributorList(adapter2, config2, _args) {
|
|
|
24843
25826
|
}
|
|
24844
25827
|
}
|
|
24845
25828
|
|
|
25829
|
+
// src/tools/task-claim.ts
|
|
25830
|
+
var taskClaimTool = {
|
|
25831
|
+
name: "task_claim",
|
|
25832
|
+
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.",
|
|
25833
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
25834
|
+
inputSchema: {
|
|
25835
|
+
type: "object",
|
|
25836
|
+
properties: {
|
|
25837
|
+
task_id: { type: "string", description: 'The task to claim, e.g. "task-2071".' }
|
|
25838
|
+
},
|
|
25839
|
+
required: ["task_id"]
|
|
25840
|
+
}
|
|
25841
|
+
};
|
|
25842
|
+
var taskUnclaimTool = {
|
|
25843
|
+
name: "task_unclaim",
|
|
25844
|
+
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.",
|
|
25845
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
25846
|
+
inputSchema: {
|
|
25847
|
+
type: "object",
|
|
25848
|
+
properties: {
|
|
25849
|
+
task_id: { type: "string", description: 'The task to unclaim, e.g. "task-2071".' }
|
|
25850
|
+
},
|
|
25851
|
+
required: ["task_id"]
|
|
25852
|
+
}
|
|
25853
|
+
};
|
|
25854
|
+
var DONE_STATUSES = /* @__PURE__ */ new Set(["Done", "Cancelled", "Archived"]);
|
|
25855
|
+
function requireTaskId(args) {
|
|
25856
|
+
const id = typeof args.task_id === "string" ? args.task_id.trim() : "";
|
|
25857
|
+
return id.length > 0 ? id : null;
|
|
25858
|
+
}
|
|
25859
|
+
async function resolveClaimerIdentity(adapter2, config2) {
|
|
25860
|
+
if (typeof adapter2.claimTask !== "function") {
|
|
25861
|
+
return { error: "Claiming is not available on this adapter." };
|
|
25862
|
+
}
|
|
25863
|
+
const gate = await resolveOwnerGate(adapter2, config2);
|
|
25864
|
+
const callerUserId = gate.callerUserId;
|
|
25865
|
+
if (!callerUserId) {
|
|
25866
|
+
const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
|
|
25867
|
+
return {
|
|
25868
|
+
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."
|
|
25869
|
+
};
|
|
25870
|
+
}
|
|
25871
|
+
if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
|
|
25872
|
+
try {
|
|
25873
|
+
const members = await adapter2.listContributors();
|
|
25874
|
+
if (!members.some((m) => m.userId === callerUserId)) {
|
|
25875
|
+
return { error: "Only the project owner or an active contributor can claim tasks on this project." };
|
|
25876
|
+
}
|
|
25877
|
+
} catch (err) {
|
|
25878
|
+
return { error: `Could not verify membership: ${err instanceof Error ? err.message : String(err)}` };
|
|
25879
|
+
}
|
|
25880
|
+
}
|
|
25881
|
+
return { callerUserId };
|
|
25882
|
+
}
|
|
25883
|
+
async function collectClaimChain(adapter2, root) {
|
|
25884
|
+
const byId = /* @__PURE__ */ new Map([[root.id, root]]);
|
|
25885
|
+
const queue = [root];
|
|
25886
|
+
while (queue.length > 0) {
|
|
25887
|
+
const t = queue.shift();
|
|
25888
|
+
if (!t.dependsOn) continue;
|
|
25889
|
+
const depIds = t.dependsOn.split(",").map((d) => d.trim()).filter(Boolean).filter((d) => !byId.has(d));
|
|
25890
|
+
if (depIds.length === 0) continue;
|
|
25891
|
+
const deps = await adapter2.getTasks(depIds);
|
|
25892
|
+
for (const dep of deps) {
|
|
25893
|
+
if (byId.has(dep.id)) continue;
|
|
25894
|
+
byId.set(dep.id, dep);
|
|
25895
|
+
if (!DONE_STATUSES.has(dep.status)) queue.push(dep);
|
|
25896
|
+
}
|
|
25897
|
+
}
|
|
25898
|
+
return [...byId.values()].filter((t) => !DONE_STATUSES.has(t.status));
|
|
25899
|
+
}
|
|
25900
|
+
async function handleTaskClaim(adapter2, config2, args) {
|
|
25901
|
+
const taskId = requireTaskId(args);
|
|
25902
|
+
if (!taskId) return errorResponse('A task_id is required. Example: task_claim task_id="task-2071"');
|
|
25903
|
+
const identity = await resolveClaimerIdentity(adapter2, config2);
|
|
25904
|
+
if ("error" in identity) return errorResponse(identity.error);
|
|
25905
|
+
const me = identity.callerUserId;
|
|
25906
|
+
const root = await adapter2.getTask(taskId);
|
|
25907
|
+
if (!root) return errorResponse(`Task "${taskId}" not found on the board.`);
|
|
25908
|
+
if (DONE_STATUSES.has(root.status)) {
|
|
25909
|
+
return errorResponse(`Task "${taskId}" is ${root.status} \u2014 nothing to claim.`);
|
|
25910
|
+
}
|
|
25911
|
+
const chain = await collectClaimChain(adapter2, root);
|
|
25912
|
+
const blockedByOther = chain.filter((t) => t.assigneeId && t.assigneeId !== me);
|
|
25913
|
+
const inOthersCycle = chain.filter((t) => !t.assigneeId && t.cycle != null);
|
|
25914
|
+
if (blockedByOther.length > 0 || inOthersCycle.length > 0) {
|
|
25915
|
+
const lines = [];
|
|
25916
|
+
for (const t of blockedByOther) lines.push(`- ${t.id} (${t.title}) \u2014 already claimed by another member`);
|
|
25917
|
+
for (const t of inOthersCycle) lines.push(`- ${t.id} (${t.title}) \u2014 already pulled into a cycle (not in the pool)`);
|
|
25918
|
+
return errorResponse(
|
|
25919
|
+
`Cannot claim "${taskId}" \u2014 it shares a DEPENDS ON build unit with task(s) you cannot take:
|
|
25920
|
+
${lines.join("\n")}
|
|
25921
|
+
|
|
25922
|
+
A build unit is never split across owners. Ask the current owner to unclaim, or pick a different task.`
|
|
25923
|
+
);
|
|
25924
|
+
}
|
|
25925
|
+
const alreadyMine = chain.filter((t) => t.assigneeId === me);
|
|
25926
|
+
const toClaim = chain.filter((t) => !t.assigneeId && t.cycle == null);
|
|
25927
|
+
const claimed = [];
|
|
25928
|
+
const raced = [];
|
|
25929
|
+
for (const t of toClaim) {
|
|
25930
|
+
const result = await adapter2.claimTask(t.id, me);
|
|
25931
|
+
if (result) claimed.push(result);
|
|
25932
|
+
else raced.push(t.id);
|
|
25933
|
+
}
|
|
25934
|
+
if (claimed.length === 0 && alreadyMine.length > 0 && raced.length === 0) {
|
|
25935
|
+
return textResponse(`Task "${taskId}" and its prerequisites are already in your backlog \u2014 nothing to do.`);
|
|
25936
|
+
}
|
|
25937
|
+
const parts = [];
|
|
25938
|
+
const claimedList = claimed.map((t) => `- ${t.id} (${t.title})`).join("\n");
|
|
25939
|
+
parts.push(
|
|
25940
|
+
`\u2705 Claimed **${claimed.length}** task(s) into your backlog (assignee = you, not yet in a cycle):
|
|
25941
|
+
${claimedList}`
|
|
25942
|
+
);
|
|
25943
|
+
if (alreadyMine.length > 0) {
|
|
25944
|
+
parts.push(`Already yours: ${alreadyMine.map((t) => t.id).join(", ")}.`);
|
|
25945
|
+
}
|
|
25946
|
+
if (raced.length > 0) {
|
|
25947
|
+
parts.push(
|
|
25948
|
+
`\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.`
|
|
25949
|
+
);
|
|
25950
|
+
}
|
|
25951
|
+
parts.push("Next: run `plan` to draw a cycle from your backlog, or `task_unclaim` to release.");
|
|
25952
|
+
return textResponse(parts.join("\n\n"));
|
|
25953
|
+
}
|
|
25954
|
+
async function handleTaskUnclaim(adapter2, config2, args) {
|
|
25955
|
+
const taskId = requireTaskId(args);
|
|
25956
|
+
if (!taskId) return errorResponse('A task_id is required. Example: task_unclaim task_id="task-2071"');
|
|
25957
|
+
if (typeof adapter2.unclaimTask !== "function") {
|
|
25958
|
+
return errorResponse("Unclaiming is not available on this adapter.");
|
|
25959
|
+
}
|
|
25960
|
+
const identity = await resolveClaimerIdentity(adapter2, config2);
|
|
25961
|
+
if ("error" in identity) return errorResponse(identity.error);
|
|
25962
|
+
const me = identity.callerUserId;
|
|
25963
|
+
const result = await adapter2.unclaimTask(taskId, me);
|
|
25964
|
+
if (!result) {
|
|
25965
|
+
const task = await adapter2.getTask(taskId);
|
|
25966
|
+
if (!task) return errorResponse(`Task "${taskId}" not found on the board.`);
|
|
25967
|
+
if (task.assigneeId && task.assigneeId !== me) {
|
|
25968
|
+
return errorResponse(`Task "${taskId}" is claimed by another member \u2014 only the claimer can unclaim it.`);
|
|
25969
|
+
}
|
|
25970
|
+
if (task.status === "In Review" || task.status === "Done") {
|
|
25971
|
+
return errorResponse(`Task "${taskId}" is ${task.status} \u2014 it has passed the pre-review window and cannot be unclaimed.`);
|
|
25972
|
+
}
|
|
25973
|
+
return errorResponse(`Task "${taskId}" is not currently claimed by you \u2014 nothing to unclaim.`);
|
|
25974
|
+
}
|
|
25975
|
+
return textResponse(`\u2705 Released **${result.id}** (${result.title}) back to the shared Pool.`);
|
|
25976
|
+
}
|
|
25977
|
+
|
|
24846
25978
|
// src/services/harness-inventory.ts
|
|
24847
25979
|
import { readdir as readdir3, readFile as readFile8, stat as stat3 } from "fs/promises";
|
|
24848
25980
|
import { join as join16 } from "path";
|
|
@@ -24944,8 +26076,8 @@ async function syncHarnessInventory(adapter2, config2, opts) {
|
|
|
24944
26076
|
}
|
|
24945
26077
|
const fingerprint = await computeFingerprint(config2.projectRoot);
|
|
24946
26078
|
if (!opts?.force) {
|
|
24947
|
-
const
|
|
24948
|
-
if (
|
|
26079
|
+
const state = await adapter2.getHarnessState();
|
|
26080
|
+
if (state && state.fingerprint === fingerprint) return { changed: false };
|
|
24949
26081
|
}
|
|
24950
26082
|
const entries = await scanInventory(config2.projectRoot, opts?.toolDefs);
|
|
24951
26083
|
await adapter2.replaceHarnessInventory(entries);
|
|
@@ -25018,6 +26150,8 @@ var ABUSE_CEILINGS = {
|
|
|
25018
26150
|
var DEFAULT_CEILING = ABUSE_CEILINGS.free;
|
|
25019
26151
|
var ALWAYS_FREE_TOOLS = /* @__PURE__ */ new Set([
|
|
25020
26152
|
"orient",
|
|
26153
|
+
"papi",
|
|
26154
|
+
// alias for orient (task-2223) — must share its free treatment
|
|
25021
26155
|
"board_view",
|
|
25022
26156
|
"build_list",
|
|
25023
26157
|
"build_describe",
|
|
@@ -25077,6 +26211,7 @@ If this is legitimate, reach out at https://getpapi.ai and we'll lift it \u2014
|
|
|
25077
26211
|
// src/server.ts
|
|
25078
26212
|
var DEFAULT_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_TOOL_TIMEOUT_MS ?? "30000", 10);
|
|
25079
26213
|
var LONG_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_LONG_TOOL_TIMEOUT_MS ?? "180000", 10);
|
|
26214
|
+
var WEDGE_PENDING_FRACTION = Math.min(1, Math.max(0, parseFloat(process.env.PAPI_WEDGE_PENDING_FRACTION ?? "0.6")));
|
|
25080
26215
|
var LONG_RUNNING_TOOLS = /* @__PURE__ */ new Set([
|
|
25081
26216
|
"plan",
|
|
25082
26217
|
"strategy_review",
|
|
@@ -25093,6 +26228,31 @@ function toolTimeoutMs(name) {
|
|
|
25093
26228
|
}
|
|
25094
26229
|
var wedgeRecoveryStats = { count: 0 };
|
|
25095
26230
|
var inflightWork = /* @__PURE__ */ new Map();
|
|
26231
|
+
var ToolTimeoutError = class extends Error {
|
|
26232
|
+
constructor(toolName, expectedMs, actualMs, wedged, pendingNote) {
|
|
26233
|
+
super(
|
|
26234
|
+
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\`).`
|
|
26235
|
+
);
|
|
26236
|
+
this.toolName = toolName;
|
|
26237
|
+
this.expectedMs = expectedMs;
|
|
26238
|
+
this.actualMs = actualMs;
|
|
26239
|
+
this.wedged = wedged;
|
|
26240
|
+
this.pendingNote = pendingNote;
|
|
26241
|
+
this.name = "ToolTimeoutError";
|
|
26242
|
+
}
|
|
26243
|
+
};
|
|
26244
|
+
function classifyTimeout(start, actualMs, budgetMs) {
|
|
26245
|
+
const now = start + actualMs;
|
|
26246
|
+
let oldestPendingMs = 0;
|
|
26247
|
+
const parts = Array.from(inflightWork.entries()).map(([slot, slotStart]) => {
|
|
26248
|
+
const age = now - slotStart;
|
|
26249
|
+
if (age > oldestPendingMs) oldestPendingMs = age;
|
|
26250
|
+
return `${slot}(${age}ms)`;
|
|
26251
|
+
});
|
|
26252
|
+
const pendingNote = parts.length ? ` Pending work at timeout: ${parts.join(", ")}.` : " No pending work registered.";
|
|
26253
|
+
const wedged = oldestPendingMs >= budgetMs * WEDGE_PENDING_FRACTION;
|
|
26254
|
+
return { wedged, pendingNote };
|
|
26255
|
+
}
|
|
25096
26256
|
async function withToolTimeout(name, fn) {
|
|
25097
26257
|
const ms = toolTimeoutMs(name);
|
|
25098
26258
|
const start = Date.now();
|
|
@@ -25100,15 +26260,18 @@ async function withToolTimeout(name, fn) {
|
|
|
25100
26260
|
const timeoutPromise = new Promise((_, reject) => {
|
|
25101
26261
|
timer2 = setTimeout(() => {
|
|
25102
26262
|
const actualMs = Date.now() - start;
|
|
25103
|
-
|
|
25104
|
-
|
|
25105
|
-
|
|
25106
|
-
|
|
25107
|
-
|
|
25108
|
-
|
|
25109
|
-
|
|
25110
|
-
|
|
25111
|
-
|
|
26263
|
+
const { wedged, pendingNote } = classifyTimeout(start, actualMs, ms);
|
|
26264
|
+
if (wedged) {
|
|
26265
|
+
wedgeRecoveryStats.count += 1;
|
|
26266
|
+
console.error(
|
|
26267
|
+
`[papi] Wedge-recovery #${wedgeRecoveryStats.count}: tool '${name}' timed out with a stuck operation (expected=${ms}ms, actual=${actualMs}ms) (SUP-2026-012).${pendingNote}`
|
|
26268
|
+
);
|
|
26269
|
+
} else {
|
|
26270
|
+
console.error(
|
|
26271
|
+
`[papi] Slow timeout (DB healthy, not a wedge): tool '${name}' exceeded its ${ms}ms budget (actual=${actualMs}ms) via cumulative load.${pendingNote}`
|
|
26272
|
+
);
|
|
26273
|
+
}
|
|
26274
|
+
reject(new ToolTimeoutError(name, ms, actualMs, wedged, pendingNote));
|
|
25112
26275
|
}, ms);
|
|
25113
26276
|
timer2.unref();
|
|
25114
26277
|
});
|
|
@@ -25138,6 +26301,7 @@ var TOOLS_REQUIRING_PAPI = /* @__PURE__ */ new Set([
|
|
|
25138
26301
|
"review_list",
|
|
25139
26302
|
"review_submit",
|
|
25140
26303
|
"orient",
|
|
26304
|
+
"papi",
|
|
25141
26305
|
"hierarchy_update",
|
|
25142
26306
|
"zoom_out",
|
|
25143
26307
|
"handoff_generate",
|
|
@@ -25166,8 +26330,10 @@ var PAPI_TOOLS = [
|
|
|
25166
26330
|
releaseTool,
|
|
25167
26331
|
reviewListTool,
|
|
25168
26332
|
reviewSubmitTool,
|
|
26333
|
+
reviewClaimTool,
|
|
25169
26334
|
initTool,
|
|
25170
26335
|
orientTool,
|
|
26336
|
+
papiTool,
|
|
25171
26337
|
hierarchyUpdateTool,
|
|
25172
26338
|
zoomOutTool,
|
|
25173
26339
|
docRegisterTool,
|
|
@@ -25187,6 +26353,8 @@ var PAPI_TOOLS = [
|
|
|
25187
26353
|
contributorAddTool,
|
|
25188
26354
|
contributorRemoveTool,
|
|
25189
26355
|
contributorListTool,
|
|
26356
|
+
taskClaimTool,
|
|
26357
|
+
taskUnclaimTool,
|
|
25190
26358
|
inventorySyncTool
|
|
25191
26359
|
];
|
|
25192
26360
|
function getToolMetadata() {
|
|
@@ -25290,7 +26458,8 @@ function createServer(adapter2, config2) {
|
|
|
25290
26458
|
}
|
|
25291
26459
|
}
|
|
25292
26460
|
const timer2 = startTimer();
|
|
25293
|
-
|
|
26461
|
+
const callerKey = callerKeyFromConfig(config2);
|
|
26462
|
+
recordToolCall(name, callerKey);
|
|
25294
26463
|
const runHandler = async () => {
|
|
25295
26464
|
switch (name) {
|
|
25296
26465
|
case "plan":
|
|
@@ -25333,10 +26502,15 @@ function createServer(adapter2, config2) {
|
|
|
25333
26502
|
return handleReviewList(adapter2, config2);
|
|
25334
26503
|
case "review_submit":
|
|
25335
26504
|
return handleReviewSubmit(adapter2, config2, safeArgs);
|
|
26505
|
+
case "review_claim":
|
|
26506
|
+
return handleReviewClaim(adapter2, config2, safeArgs);
|
|
25336
26507
|
case "init":
|
|
25337
26508
|
return handleInit(config2, safeArgs);
|
|
25338
26509
|
case "orient":
|
|
25339
26510
|
return handleOrient(adapter2, config2, safeArgs);
|
|
26511
|
+
case "papi":
|
|
26512
|
+
return handleOrient(adapter2, config2, safeArgs);
|
|
26513
|
+
// alias (task-2223)
|
|
25340
26514
|
case "hierarchy_update":
|
|
25341
26515
|
return handleHierarchyUpdate(adapter2, safeArgs);
|
|
25342
26516
|
case "zoom_out":
|
|
@@ -25375,6 +26549,10 @@ function createServer(adapter2, config2) {
|
|
|
25375
26549
|
return handleContributorRemove(adapter2, config2, safeArgs);
|
|
25376
26550
|
case "contributor_list":
|
|
25377
26551
|
return handleContributorList(adapter2, config2, safeArgs);
|
|
26552
|
+
case "task_claim":
|
|
26553
|
+
return handleTaskClaim(adapter2, config2, safeArgs);
|
|
26554
|
+
case "task_unclaim":
|
|
26555
|
+
return handleTaskUnclaim(adapter2, config2, safeArgs);
|
|
25378
26556
|
case "inventory_sync":
|
|
25379
26557
|
return handleInventorySync(adapter2, config2, safeArgs, getToolMetadata());
|
|
25380
26558
|
default:
|
|
@@ -25383,7 +26561,7 @@ function createServer(adapter2, config2) {
|
|
|
25383
26561
|
};
|
|
25384
26562
|
const meterDecision = await checkMeter(adapter2, name, config2.projectId ?? "session");
|
|
25385
26563
|
if (meterDecision.blocked && meterDecision.usage) {
|
|
25386
|
-
recordToolOutcome(true);
|
|
26564
|
+
recordToolOutcome(true, callerKey);
|
|
25387
26565
|
return {
|
|
25388
26566
|
content: [{ type: "text", text: allowanceExceededMessage(name, meterDecision.usage) }]
|
|
25389
26567
|
};
|
|
@@ -25392,18 +26570,19 @@ function createServer(adapter2, config2) {
|
|
|
25392
26570
|
try {
|
|
25393
26571
|
result = await withToolTimeout(name, runHandler);
|
|
25394
26572
|
} catch (err) {
|
|
25395
|
-
|
|
25396
|
-
|
|
25397
|
-
|
|
25398
|
-
|
|
25399
|
-
|
|
25400
|
-
|
|
25401
|
-
|
|
25402
|
-
|
|
26573
|
+
if (err instanceof ToolTimeoutError) {
|
|
26574
|
+
recordToolOutcome(false, callerKey);
|
|
26575
|
+
if (err.wedged) {
|
|
26576
|
+
setImmediate(() => {
|
|
26577
|
+
console.error(`[papi] Exiting after '${name}' wedge timeout (wedge-recovery #${wedgeRecoveryStats.count}); user must reconnect with /mcp before the next call.`);
|
|
26578
|
+
process.exit(2);
|
|
26579
|
+
});
|
|
26580
|
+
}
|
|
26581
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }] };
|
|
25403
26582
|
}
|
|
25404
26583
|
throw err;
|
|
25405
26584
|
}
|
|
25406
|
-
if (name === "orient" && adapter2.getMeteredUsage && !result.content.some((c) => c.text.startsWith("Error:"))) {
|
|
26585
|
+
if ((name === "orient" || name === "papi") && adapter2.getMeteredUsage && !result.content.some((c) => c.text.startsWith("Error:"))) {
|
|
25407
26586
|
try {
|
|
25408
26587
|
const decision = await checkMeter(adapter2, "plan", config2.projectId ?? "session");
|
|
25409
26588
|
if (decision.usage) {
|
|
@@ -25425,7 +26604,7 @@ ${usageLine(decision.usage)}`;
|
|
|
25425
26604
|
delete result._contextBytes;
|
|
25426
26605
|
delete result._contextUtilisation;
|
|
25427
26606
|
const isError = result.content.some((c) => c.text.startsWith("Error:") || c.text.startsWith("\u274C"));
|
|
25428
|
-
recordToolOutcome(!isError);
|
|
26607
|
+
recordToolOutcome(!isError, callerKey);
|
|
25429
26608
|
try {
|
|
25430
26609
|
const clientName = server2.getClientVersion()?.name;
|
|
25431
26610
|
const metric = buildMetric(name, elapsed, usage, void 0, contextBytes, contextUtilisation, clientName);
|
|
@@ -25473,6 +26652,28 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
25473
26652
|
var BEARER_PREFIX = "papi_";
|
|
25474
26653
|
var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
|
|
25475
26654
|
var RESOURCE_METADATA_URL = process.env["PAPI_RESOURCE_METADATA_URL"] ?? "https://getpapi.ai/.well-known/oauth-protected-resource";
|
|
26655
|
+
var FRIENDLY_GET_HTML = `<!doctype html>
|
|
26656
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
26657
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
26658
|
+
<title>PAPI MCP server</title>
|
|
26659
|
+
<style>
|
|
26660
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|
26661
|
+
background:#faf6f0;color:#15171c;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
|
|
26662
|
+
.card{max-width:520px;padding:40px 36px;text-align:center}
|
|
26663
|
+
h1{font-size:22px;margin:0 0 12px;letter-spacing:.2px}
|
|
26664
|
+
p{margin:10px 0;color:#394048}
|
|
26665
|
+
a.btn{display:inline-block;margin-top:18px;padding:11px 20px;border-radius:10px;
|
|
26666
|
+
background:#15171c;color:#faf6f0;text-decoration:none;font-weight:600}
|
|
26667
|
+
code{background:#efe9e0;padding:2px 6px;border-radius:5px;font-size:13px}
|
|
26668
|
+
</style></head>
|
|
26669
|
+
<body><div class="card">
|
|
26670
|
+
<h1>This is the PAPI MCP server \u{1F44B}</h1>
|
|
26671
|
+
<p>You've reached it in a browser \u2014 that's expected to look empty. This endpoint
|
|
26672
|
+
is meant to be added to your AI tool as a connector, not opened directly.</p>
|
|
26673
|
+
<p>Add it in Claude, Cursor, or any MCP client as a custom connector pointing at
|
|
26674
|
+
<code>https://mcp.getpapi.ai/mcp</code>.</p>
|
|
26675
|
+
<a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
|
|
26676
|
+
</div></body></html>`;
|
|
25476
26677
|
var MAX_BODY_BYTES = 1 * 1024 * 1024;
|
|
25477
26678
|
var IP_RATE_WINDOW_MS = 6e4;
|
|
25478
26679
|
var IP_RATE_MAX = 60;
|
|
@@ -25594,6 +26795,13 @@ function startHttpTransport(opts) {
|
|
|
25594
26795
|
}
|
|
25595
26796
|
const bearer = extractBearer(req.headers.authorization);
|
|
25596
26797
|
if (!bearer) {
|
|
26798
|
+
const accept = req.headers["accept"] ?? "";
|
|
26799
|
+
if (req.method === "GET" && accept.includes("text/html")) {
|
|
26800
|
+
logEvent({ level: "info", msg: "friendly_get", ip, status: 200 });
|
|
26801
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", ...cors });
|
|
26802
|
+
res.end(FRIENDLY_GET_HTML);
|
|
26803
|
+
return;
|
|
26804
|
+
}
|
|
25597
26805
|
const hasHeader = Boolean(req.headers.authorization);
|
|
25598
26806
|
logEvent({
|
|
25599
26807
|
level: "warn",
|
|
@@ -25707,6 +26915,37 @@ function extractProjectOverride(body) {
|
|
|
25707
26915
|
const p = args.project;
|
|
25708
26916
|
return typeof p === "string" && p.trim().length > 0 ? p.trim() : void 0;
|
|
25709
26917
|
}
|
|
26918
|
+
var PROJECT_OPTIONAL_TOOLS = /* @__PURE__ */ new Set([
|
|
26919
|
+
"init",
|
|
26920
|
+
"setup",
|
|
26921
|
+
"project_list",
|
|
26922
|
+
"project_create",
|
|
26923
|
+
"project_switch"
|
|
26924
|
+
]);
|
|
26925
|
+
function extractToolName(body) {
|
|
26926
|
+
if (!body || typeof body !== "object") return void 0;
|
|
26927
|
+
const b2 = body;
|
|
26928
|
+
if (b2.method !== "tools/call") return void 0;
|
|
26929
|
+
const name = b2.params?.name;
|
|
26930
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
26931
|
+
}
|
|
26932
|
+
function sendProjectChoice(res, origin, body, projects) {
|
|
26933
|
+
if (res.headersSent) return;
|
|
26934
|
+
const id = (body && typeof body === "object" ? body.id : null) ?? null;
|
|
26935
|
+
const list = projects.map((p) => ` \u2022 ${p.slug}${p.name ? ` \u2014 ${p.name}` : ""}`).join("\n");
|
|
26936
|
+
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):
|
|
26937
|
+
|
|
26938
|
+
${list}
|
|
26939
|
+
|
|
26940
|
+
Example: add \`project="${projects[0].slug}"\` to the tool arguments.`;
|
|
26941
|
+
const payload = {
|
|
26942
|
+
jsonrpc: "2.0",
|
|
26943
|
+
id,
|
|
26944
|
+
result: { content: [{ type: "text", text }], isError: true }
|
|
26945
|
+
};
|
|
26946
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
|
|
26947
|
+
res.end(JSON.stringify(payload));
|
|
26948
|
+
}
|
|
25710
26949
|
function resolveEffectiveProjectId(body, headerProjectId) {
|
|
25711
26950
|
const explicitProject = extractProjectOverride(body);
|
|
25712
26951
|
try {
|
|
@@ -25718,7 +26957,37 @@ function resolveEffectiveProjectId(body, headerProjectId) {
|
|
|
25718
26957
|
}
|
|
25719
26958
|
async function dispatchRequest(args) {
|
|
25720
26959
|
const { req, res, body, bearer, projectId, ip, baseConfig, dataEndpoint } = args;
|
|
25721
|
-
|
|
26960
|
+
let effectiveProjectId = resolveEffectiveProjectId(body, projectId);
|
|
26961
|
+
if (effectiveProjectId === void 0) {
|
|
26962
|
+
const toolName = extractToolName(body);
|
|
26963
|
+
if (toolName && !PROJECT_OPTIONAL_TOOLS.has(toolName)) {
|
|
26964
|
+
let projects = [];
|
|
26965
|
+
try {
|
|
26966
|
+
const probe = new ProxyPapiAdapter({ endpoint: dataEndpoint, apiKey: bearer });
|
|
26967
|
+
projects = await probe.listUserProjects();
|
|
26968
|
+
} catch {
|
|
26969
|
+
}
|
|
26970
|
+
if (projects.length === 1) {
|
|
26971
|
+
effectiveProjectId = projects[0].id;
|
|
26972
|
+
logEvent({
|
|
26973
|
+
level: "info",
|
|
26974
|
+
msg: "project_autobound",
|
|
26975
|
+
ip,
|
|
26976
|
+
bearer_prefix: bearerPrefix(bearer),
|
|
26977
|
+
project_id: effectiveProjectId
|
|
26978
|
+
});
|
|
26979
|
+
} else if (projects.length > 1) {
|
|
26980
|
+
logEvent({
|
|
26981
|
+
level: "info",
|
|
26982
|
+
msg: "project_choice_returned",
|
|
26983
|
+
ip,
|
|
26984
|
+
bearer_prefix: bearerPrefix(bearer)
|
|
26985
|
+
});
|
|
26986
|
+
sendProjectChoice(res, req.headers.origin, body, projects);
|
|
26987
|
+
return;
|
|
26988
|
+
}
|
|
26989
|
+
}
|
|
26990
|
+
}
|
|
25722
26991
|
const adapter2 = new ProxyPapiAdapter({
|
|
25723
26992
|
endpoint: dataEndpoint,
|
|
25724
26993
|
apiKey: bearer,
|
|
@@ -25903,7 +27172,7 @@ If you haven't set up PAPI yet:
|
|
|
25903
27172
|
2. Complete the onboarding wizard \u2014 it generates your config
|
|
25904
27173
|
3. Copy the config to your project and restart your AI tool
|
|
25905
27174
|
|
|
25906
|
-
If you already have an account, check that both **PAPI_PROJECT_ID** and **PAPI_DATA_API_KEY** are set in your .mcp.json env config.`
|
|
27175
|
+
If you already have an account, check that both **PAPI_PROJECT_ID** and **PAPI_DATA_API_KEY** are set in your .mcp.json env config.` + HELP_FOOTER_MD
|
|
25907
27176
|
}]
|
|
25908
27177
|
}));
|
|
25909
27178
|
}
|