mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
46f1e8d5b2
Ports five small items from the closed parallel PR #1185 into the
tolerant annotate argument resolution that landed in #1183 (#1182):
- Bug fix: the token probe tested the raw token against the URL regex,
but the pipeline strips the @ reference marker and wrapping quotes
first, so a multi-token 'annotate @https://example.com/page and
summarize it' probed to nothing and emitted the handoff instead of
opening the URL. The probe now unwraps with stripAtPrefix before the
regex and returns the unwrapped form (the pipeline re-strips
harmlessly). Tests cover @-prefixed and quote-wrapped URLs as
multi-token candidates.
- Test ports: absolute-path candidate, the wider plain-text set (.txt,
.yaml) guarding ANNOTATABLE_DOC_REGEX breadth, the scoped-package
literal-@ fallback against a real @scope/ directory, and the
whole-un-split-string preference over its own tokens ('Meeting
Notes.md' wins over a resolving 'Notes.md' token) covering
annotateInputNamesExistingTarget.
- Defensive scan: the strict-mode source-scan test now asserts the
annotate startup block gates tolerance on !strictAnnotate via
isStrictAnnotateInvocation, since an inverted gate cannot be
spawn-tested without starting a server.
- DRY: the strict predicate was defined twice (strict-annotate-result
exit-code helper and the index.ts tolerance bypass). Extracted
isStrictAnnotateInvocation with a StrictAnnotateFlags type; both
sites use it so the exit-code path and the tolerance bypass can
never drift. Behavior byte-identical; existing subprocess tests
unchanged.
- Docs: the tolerant-resolution section now cites #872 (commit
aac5aacb) for why the bang prefix is deliberate and states that
argument-shape issues belong in the CLI's resolution, not the skill
templates.
Refs #1185, #1182
Co-authored-by: Josh Nichols <josh.nichols+agent@gusto.com>
158 lines
4.7 KiB
TypeScript
158 lines
4.7 KiB
TypeScript
import { existsSync, lstatSync, statSync } from "node:fs";
|
|
import {
|
|
link,
|
|
open,
|
|
unlink,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { randomUUID } from "node:crypto";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
|
|
export interface AnnotateOutcome {
|
|
feedback: string;
|
|
exit?: boolean;
|
|
approved?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Exit code for gate errors, following the grep convention:
|
|
* `0` = approved, `1` = negative human outcome (annotated/dismissed under
|
|
* `--require-approval`), `2` = the gate itself was misconfigured, could not
|
|
* start, or could not publish its result file (usage, startup, validation, and
|
|
* publication failures). Exit `2` never reports a reviewer outcome; when the
|
|
* decision itself completed, the stdout record is still emitted before exiting.
|
|
*/
|
|
export const STRICT_GATE_ERROR_EXIT_CODE = 2;
|
|
|
|
export interface StrictAnnotateFlags {
|
|
requireApproval: boolean;
|
|
resultFile?: string;
|
|
}
|
|
|
|
/**
|
|
* True when the invocation carries a strict flag (`--require-approval` /
|
|
* `--result-file`, neither of which the CLI accepts without `--gate --json`).
|
|
*
|
|
* Strict invocations own the exit-code contract below, which is why tolerant
|
|
* annotate argument resolution is bypassed for them: quietly annotating a
|
|
* later argument because the first one was a typo would let a gate publish
|
|
* "approved" for a document the caller never named. A typo must keep exiting
|
|
* 2 here. This predicate is the single definition both the exit-code path
|
|
* and the tolerance bypass read, so the two can never drift.
|
|
*/
|
|
export function isStrictAnnotateInvocation(
|
|
flags: StrictAnnotateFlags,
|
|
): boolean {
|
|
return flags.requireApproval || !!flags.resultFile;
|
|
}
|
|
|
|
/**
|
|
* Exit code for an annotate startup failure (missing path, unreachable URL,
|
|
* empty folder, ambiguous name, missing file, oversized file).
|
|
*
|
|
* Legacy invocations keep exiting `1`. Under a strict flag, `1` is reserved for
|
|
* "the reviewer did not approve", so a startup failure must exit with the gate
|
|
* error code instead — otherwise automation reads a typo'd path as a rejection.
|
|
*/
|
|
export function annotateStartupFailureExitCode(
|
|
strict: StrictAnnotateFlags,
|
|
): number {
|
|
return isStrictAnnotateInvocation(strict) ? STRICT_GATE_ERROR_EXIT_CODE : 1;
|
|
}
|
|
|
|
export function serializeStrictAnnotateResult(
|
|
result: AnnotateOutcome,
|
|
): string {
|
|
if (result.approved) {
|
|
return JSON.stringify({
|
|
decision: "approved",
|
|
...(result.feedback ? { feedback: result.feedback } : {}),
|
|
});
|
|
}
|
|
if (result.exit) return JSON.stringify({ decision: "dismissed" });
|
|
return JSON.stringify({
|
|
decision: "annotated",
|
|
feedback: result.feedback || "",
|
|
});
|
|
}
|
|
|
|
export function annotateOutcomeExitCode(
|
|
result: AnnotateOutcome,
|
|
requireApproval: boolean,
|
|
): number {
|
|
return requireApproval && !result.approved ? 1 : 0;
|
|
}
|
|
|
|
export function resolveResultFilePath(
|
|
resultFile: string,
|
|
invocationCwd: string,
|
|
): string {
|
|
return resolve(invocationCwd, resultFile);
|
|
}
|
|
|
|
export async function assertResultPathAvailable(
|
|
resultFile: string,
|
|
): Promise<void> {
|
|
let destinationExists = existsSync(resultFile);
|
|
if (!destinationExists) {
|
|
try {
|
|
lstatSync(resultFile);
|
|
destinationExists = true;
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof Error) ||
|
|
!("code" in error) ||
|
|
error.code !== "ENOENT"
|
|
) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
if (destinationExists) {
|
|
throw new Error(`Result file already exists: ${resultFile}`);
|
|
}
|
|
const parent = dirname(resultFile);
|
|
if (!existsSync(parent) || !statSync(parent).isDirectory()) {
|
|
throw new Error(`Result file parent does not exist: ${parent}`);
|
|
}
|
|
}
|
|
|
|
interface ResultFileOperations {
|
|
open: typeof open;
|
|
link: typeof link;
|
|
unlink: typeof unlink;
|
|
write: (handle: FileHandle, contents: string) => Promise<unknown>;
|
|
}
|
|
|
|
const defaultResultFileOperations: ResultFileOperations = {
|
|
open,
|
|
link,
|
|
unlink,
|
|
write: (handle, contents) => handle.writeFile(contents, "utf8"),
|
|
};
|
|
|
|
export async function writeAnnotateResultFile(
|
|
resultFile: string,
|
|
serialized: string,
|
|
operations: ResultFileOperations = defaultResultFileOperations,
|
|
): Promise<void> {
|
|
const temporary = join(
|
|
dirname(resultFile),
|
|
`.${basename(resultFile)}.${process.pid}.${randomUUID()}.tmp`,
|
|
);
|
|
let handle: FileHandle | null = null;
|
|
try {
|
|
handle = await operations.open(temporary, "wx", 0o600);
|
|
await operations.write(handle, `${serialized}\n`);
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = null;
|
|
await operations.link(temporary, resultFile);
|
|
await operations.unlink(temporary);
|
|
} catch (error) {
|
|
await handle?.close().catch(() => {});
|
|
await operations.unlink(temporary).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|