Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions plugins/waypoint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Fixed

- `project-navigator`: answering "工单审阅" (order review) with anything other than A discarded the review, so withdrawing that message and answering "发布工单" (issue the order) left the order unissuable until it was reviewed again. The review is now kept: a later message that starts with "发布工单" approves the reviewed content as long as the order file is unchanged; a bare letter counts only right after the review. The refusal message also says that running the `reply` command alone is not a review.

## [1.3.1] - 2026-11-11

### Fixed
Expand Down
54 changes: 27 additions & 27 deletions plugins/waypoint/docs/project-navigator-maintaining.en.md

Large diffs are not rendered by default.

54 changes: 27 additions & 27 deletions plugins/waypoint/docs/project-navigator-maintaining.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ function assertIssuable(context) {
throw new WorkflowError(
approval === APPROVAL_STATUSES.awaiting
? `工单 ${order.id} 正在等用户审阅: 用户在 "${APPROVAL_REPLY_TYPE}" 中选 A 之后才能发布.`
: `工单 ${order.id} 还没有经用户审阅, 或审阅之后内容有改动: 先${replyStep(context.spec, APPROVAL_REPLY_TYPE)}, 用户选 A 后再运行 order set issued.`,
: `工单 ${order.id} 还没有经用户审阅, 或审阅之后内容有改动: 先${replyStep(context.spec, APPROVAL_REPLY_TYPE)}并结束本轮, 用户选 A 后再运行 order set issued. 只运行 reply 命令不算审阅.`,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,30 @@ export function isChoiceSelected(prompt, optionSet, index) {
if (letter === undefined || choice === undefined) {
return false;
}
const trimmed = prompt.trim();
const letterPattern = new RegExp(
`^[${letter}${letter.toLowerCase()}](?:[\\s.,:;)]|$)`,
"u",
);
const lead = choiceLead(choice);
return (
letterPattern.test(trimmed) || (lead !== "" && trimmed.startsWith(lead))
letterPattern.test(prompt.trim()) || isChoiceNamed(prompt, optionSet, index)
);
}

/**
* 判断用户消息是否以某一项选项文字的第一个分句开头. 选项字母在每条回复中都有,
* 只有紧跟着那条回复时才指向这一项; 选项文字本身说明了用户要做什么, 隔了几条
* 消息之后仍然有效.
*
* @param {string} prompt 用户消息.
* @param {import("./spec.mjs").OptionSetSpec} optionSet 选项组.
* @param {number} index 选项下标, 从 0 开始.
* @returns {boolean} 以该项的第一个分句开头时返回 true.
*/
export function isChoiceNamed(prompt, optionSet, index) {
const choice = optionSet.choices[index];
if (choice === undefined) {
return false;
}
const lead = choiceLead(choice);
return lead !== "" && prompt.trim().startsWith(lead);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
* hook 在编排会话回复 "工单审阅" 时记下当时工单文件的摘要, 在用户回答时记下
* 选择; `order set issued` 只接受摘要与当前工单文件一致的认可. 认可在发布时
* 用掉; 工单内容改过之后重新发布要重新审阅, 受阻后内容没有改动时可以直接继续.
* 用户没有选发布时记录保留: 之后的消息写明 "发布工单" 时仍可认可同一份内容.
* 审阅记录存在运行期登记目录中, hook 不写状态文件.
*/

import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";

import { isChoiceSelected } from "./option-answer.mjs";
import { isChoiceNamed, isChoiceSelected } from "./option-answer.mjs";
import { orderFilePath } from "./paths.mjs";
import {
readOrderApproval,
Expand Down Expand Up @@ -41,6 +42,21 @@ export const APPROVAL_STATUSES = Object.freeze({
none: "none",
});

/**
* 审阅记录中的状态: 用户看过审阅, 但没有选发布. 对外等同于没有有效的审阅.
* @type {string}
*/
const DECLINED_STATUS = "declined";

/**
* 用户的消息还能改变结果的记录状态: 正在等用户选择, 或用户没有选发布.
* @type {readonly string[]}
*/
const ANSWERABLE_STATUSES = Object.freeze([
APPROVAL_STATUSES.awaiting,
DECLINED_STATUS,
]);

/**
* 计算工单文件内容的摘要; 统一换行符, 避免编辑器的换行差异让认可失效.
*
Expand Down Expand Up @@ -95,9 +111,10 @@ export function readApprovalStatus(projectRoot, order) {
) {
return APPROVAL_STATUSES.none;
}
return record.status === APPROVAL_STATUSES.approved
? APPROVAL_STATUSES.approved
: APPROVAL_STATUSES.awaiting;
return record.status === APPROVAL_STATUSES.approved ||
record.status === APPROVAL_STATUSES.awaiting
? record.status
: APPROVAL_STATUSES.none;
}

/**
Expand All @@ -124,8 +141,12 @@ export function markAwaitingApproval(projectRoot, order, now) {
}

/**
* 记录用户对 "工单审阅" 的回答: 选 A 时认可审阅时的内容; 其它回答撤销等待,
* 编排会话按要求修改后要重新回复 "工单审阅". 没有等待中的审阅时不做任何事.
* 记录用户对 "工单审阅" 的回答. 紧跟着审阅的那条消息选 A (字母或选项文字) 时
* 认可审阅时的内容, 其它回答记为没有选发布. 没有选发布之后, 用户的消息以
* "发布工单" 的选项文字开头时仍然认可: 用户撤回上一条消息重新回答时, 审阅回复
* 不会再结束一次, 只能靠这条记录认出来; 单独的字母此时可能在回答别的回复, 不算.
* 认可的始终是审阅时的内容, 工单文件改过之后要重新审阅. 没有针对当前工单的
* 审阅记录时不做任何事.
*
* @param {object} options 参数.
* @param {string} options.projectRoot 项目根目录.
Expand All @@ -139,22 +160,22 @@ export function applyApprovalAnswer({ projectRoot, order, prompt, spec, now }) {
const record = readOrderApproval(projectRoot);
if (
record === undefined ||
record.status !== APPROVAL_STATUSES.awaiting ||
record.order !== order?.id
record.order !== order?.id ||
!ANSWERABLE_STATUSES.includes(record.status)
) {
return;
}
const optionSet =
spec.replies[APPROVAL_REPLY_TYPE].optionSets[APPROVE_CHOICE.optionSet];
if (isChoiceSelected(prompt, optionSet, APPROVE_CHOICE.choice)) {
writeOrderApproval(projectRoot, {
...record,
status: APPROVAL_STATUSES.approved,
updatedAt: now,
});
} else {
removeOrderApproval(projectRoot);
}
const isApproved =
record.status === APPROVAL_STATUSES.awaiting
? isChoiceSelected(prompt, optionSet, APPROVE_CHOICE.choice)
: isChoiceNamed(prompt, optionSet, APPROVE_CHOICE.choice);
writeOrderApproval(projectRoot, {
...record,
status: isApproved ? APPROVAL_STATUSES.approved : DECLINED_STATUS,
updatedAt: now,
});
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ORDER_APPROVAL_FILE, registryDirectory } from "./paths.mjs";
* @typedef {object} OrderApprovalRecord 用户对一张工单的审阅.
* @property {string} order 工单编号.
* @property {string} digest 审阅时工单文件内容的摘要.
* @property {"awaiting" | "approved"} status 等待用户选择, 或用户已选发布.
* @property {"awaiting" | "approved" | "declined"} status 等待用户选择, 用户已选发布, 或用户没有选发布.
* @property {string} updatedAt 最后更新时间.
*/

Expand All @@ -42,7 +42,7 @@ export function writeOrderApproval(worktreeRoot, record) {
}

/**
* 删除工单审阅记录: 审阅被用户否决, 或已用于发布工单.
* 删除工单审阅记录: 已用于发布工单, 或工单文件不存在.
*
* @param {string} worktreeRoot 工作区根目录.
* @returns {void}
Expand Down
26 changes: 26 additions & 0 deletions tests/project-navigator/flow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,32 @@ test("工单往返: 认可之后改过工单, 发布被拒; 未验证的判据
);
});

test("工单审阅: 没有选发布之后, 写明发布工单的消息仍然认可, 单独的字母不算", (context) => {
const setup = setupProject(context);
const { root, project, orchestrator } = setup;
project(["order", "new", "--kind", "implementation", "--slug", "export"]);
const folder = ".navigator/orders/0001-export";
const order = fill(commandSkeleton(project(["template", "order"]).stdout));
writeProjectFile(root, `${folder}/order.md`, order);
stop(
root,
orchestrator,
fill(commandSkeleton(project(["reply", "approve"]).stdout)),
);
prompt(root, orchestrator, "判据 3 是什么意思?");
const refused = project(["order", "set", "issued"]);
assert.equal(refused.status, 1);
assert.match(refused.stdout, /只运行 reply 命令不算审阅/u);
prompt(root, orchestrator, "A");
assert.equal(project(["order", "set", "issued"]).status, 1);
writeProjectFile(root, `${folder}/order.md`, `${order}\n补一句.\n`);
prompt(root, orchestrator, "发布工单");
assert.equal(project(["order", "set", "issued"]).status, 1);
writeProjectFile(root, `${folder}/order.md`, order);
prompt(root, orchestrator, "发布工单");
assert.equal(project(["order", "set", "issued"]).status, 0);
});

test("基准提交: 新建工单之后仓库有新提交时, 审阅拒绝旧值, 发布后验收提示词用当前提交", (context) => {
const setup = setupProject(context);
const { root, project, orchestrator } = setup;
Expand Down
Loading