@lsctech/polaris 0.1.3 → 0.2.1
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/governance/authority-risk.js +56 -0
- package/dist/governance/index.js +12 -0
- package/dist/governance/review-packet.js +114 -0
- package/dist/governance/routing.js +63 -0
- package/dist/governance/types.js +8 -0
- package/dist/smartdocs-engine/index.js +34 -2
- package/dist/smartdocs-engine/ingest.js +146 -13
- package/dist/smartdocs-engine/review.js +149 -0
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeAuthorityRisk = computeAuthorityRisk;
|
|
4
|
+
const HIGH_AUTHORITY_PATH_SEGMENTS = [
|
|
5
|
+
"doctrine/active",
|
|
6
|
+
"architecture",
|
|
7
|
+
"decisions",
|
|
8
|
+
"specs/active",
|
|
9
|
+
];
|
|
10
|
+
const MEDIUM_AUTHORITY_PATH_SEGMENTS = [
|
|
11
|
+
"doctrine/candidate",
|
|
12
|
+
];
|
|
13
|
+
const HIGH_AUTHORITY_CLASSIFICATIONS = new Set([
|
|
14
|
+
"architecture",
|
|
15
|
+
"decision",
|
|
16
|
+
"spec-active",
|
|
17
|
+
]);
|
|
18
|
+
const MEDIUM_AUTHORITY_CLASSIFICATIONS = new Set([
|
|
19
|
+
"doctrine-candidate",
|
|
20
|
+
]);
|
|
21
|
+
function containsPathSegment(normalized, segment) {
|
|
22
|
+
return (normalized === segment ||
|
|
23
|
+
normalized.startsWith(segment + "/") ||
|
|
24
|
+
normalized.endsWith("/" + segment) ||
|
|
25
|
+
normalized.includes("/" + segment + "/"));
|
|
26
|
+
}
|
|
27
|
+
function riskFromPath(destinationPath) {
|
|
28
|
+
const normalized = destinationPath.replace(/\\/g, "/");
|
|
29
|
+
for (const seg of HIGH_AUTHORITY_PATH_SEGMENTS) {
|
|
30
|
+
if (containsPathSegment(normalized, seg))
|
|
31
|
+
return "high";
|
|
32
|
+
}
|
|
33
|
+
for (const seg of MEDIUM_AUTHORITY_PATH_SEGMENTS) {
|
|
34
|
+
if (containsPathSegment(normalized, seg))
|
|
35
|
+
return "medium";
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function riskFromClassification(classification) {
|
|
40
|
+
if (HIGH_AUTHORITY_CLASSIFICATIONS.has(classification))
|
|
41
|
+
return "high";
|
|
42
|
+
if (MEDIUM_AUTHORITY_CLASSIFICATIONS.has(classification))
|
|
43
|
+
return "medium";
|
|
44
|
+
return "low";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Determine the authority risk of placing a document at the given destination.
|
|
48
|
+
* Path wins over classification when they disagree, because authority is
|
|
49
|
+
* determined by where an artifact lands, not what it was classified as.
|
|
50
|
+
*/
|
|
51
|
+
function computeAuthorityRisk(classification, destinationPath) {
|
|
52
|
+
const pathRisk = riskFromPath(destinationPath);
|
|
53
|
+
if (pathRisk !== null)
|
|
54
|
+
return pathRisk;
|
|
55
|
+
return riskFromClassification(classification);
|
|
56
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyReviewDecisions = exports.readReviewQueue = exports.writeReviewQueue = exports.buildReviewPacket = exports.route = exports.computeAuthorityRisk = void 0;
|
|
4
|
+
var authority_risk_js_1 = require("./authority-risk.js");
|
|
5
|
+
Object.defineProperty(exports, "computeAuthorityRisk", { enumerable: true, get: function () { return authority_risk_js_1.computeAuthorityRisk; } });
|
|
6
|
+
var routing_js_1 = require("./routing.js");
|
|
7
|
+
Object.defineProperty(exports, "route", { enumerable: true, get: function () { return routing_js_1.route; } });
|
|
8
|
+
var review_packet_js_1 = require("./review-packet.js");
|
|
9
|
+
Object.defineProperty(exports, "buildReviewPacket", { enumerable: true, get: function () { return review_packet_js_1.buildReviewPacket; } });
|
|
10
|
+
Object.defineProperty(exports, "writeReviewQueue", { enumerable: true, get: function () { return review_packet_js_1.writeReviewQueue; } });
|
|
11
|
+
Object.defineProperty(exports, "readReviewQueue", { enumerable: true, get: function () { return review_packet_js_1.readReviewQueue; } });
|
|
12
|
+
Object.defineProperty(exports, "applyReviewDecisions", { enumerable: true, get: function () { return review_packet_js_1.applyReviewDecisions; } });
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildReviewPacket = buildReviewPacket;
|
|
4
|
+
exports.writeReviewQueue = writeReviewQueue;
|
|
5
|
+
exports.readReviewQueue = readReviewQueue;
|
|
6
|
+
exports.applyReviewDecisions = applyReviewDecisions;
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
function buildReviewPacket(result, sourcePath, proposedDestination, conflicts, outcomeReason, recommendation) {
|
|
10
|
+
return {
|
|
11
|
+
sourcePath,
|
|
12
|
+
proposedDestination,
|
|
13
|
+
classificationConfidence: result.classificationConfidence,
|
|
14
|
+
destinationCertainty: result.destinationCertainty,
|
|
15
|
+
authorityRisk: result.authorityRisk,
|
|
16
|
+
reasoning: result.reasoning,
|
|
17
|
+
conflicts,
|
|
18
|
+
recommendation,
|
|
19
|
+
outcomeReason,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const RISK_ORDER = { high: 0, medium: 1, low: 2 };
|
|
23
|
+
function renderPacketMarkdown(p) {
|
|
24
|
+
const lines = [
|
|
25
|
+
`## ${p.reviewDecision ? `✓ ${p.reviewDecision}` : "review-required"} · ${p.authorityRisk.toUpperCase()} authority risk`,
|
|
26
|
+
``,
|
|
27
|
+
`**Source:** ${p.sourcePath}`,
|
|
28
|
+
`**Proposed destination:** ${p.proposedDestination}`,
|
|
29
|
+
`**Classification confidence:** ${p.classificationConfidence.toFixed(2)}`,
|
|
30
|
+
`**Destination certainty:** ${p.destinationCertainty.toFixed(2)}`,
|
|
31
|
+
`**Outcome reason:** ${p.outcomeReason}`,
|
|
32
|
+
``,
|
|
33
|
+
`**Reasoning:**`,
|
|
34
|
+
...p.reasoning.map((r) => `- ${r}`),
|
|
35
|
+
``,
|
|
36
|
+
`**Conflicts:** ${p.conflicts.length === 0 ? "none detected" : p.conflicts.join(", ")}`,
|
|
37
|
+
``,
|
|
38
|
+
`**Recommendation:** ${p.recommendation}`,
|
|
39
|
+
`**Review decision:** ${p.reviewDecision ?? "← set this to \`approve\`, \`reject\`, or \`defer\`"}`,
|
|
40
|
+
];
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
function groupAndSort(packets) {
|
|
44
|
+
return [...packets].sort((a, b) => {
|
|
45
|
+
const riskCompare = RISK_ORDER[a.authorityRisk] - RISK_ORDER[b.authorityRisk];
|
|
46
|
+
if (riskCompare !== 0)
|
|
47
|
+
return riskCompare;
|
|
48
|
+
return a.sourcePath.localeCompare(b.sourcePath);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Write _review-queue.json (canonical) and _review-queue.md (display-only) to outputDir.
|
|
53
|
+
* Markdown is regenerated from JSON — never parse markdown to recover decisions.
|
|
54
|
+
*/
|
|
55
|
+
function writeReviewQueue(packets, runId, outputDir) {
|
|
56
|
+
(0, node_fs_1.mkdirSync)(outputDir, { recursive: true });
|
|
57
|
+
const queueFile = {
|
|
58
|
+
generated_at: new Date().toISOString(),
|
|
59
|
+
run_id: runId,
|
|
60
|
+
packets,
|
|
61
|
+
};
|
|
62
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, "_review-queue.json"), JSON.stringify(queueFile, null, 2) + "\n", "utf-8");
|
|
63
|
+
const sorted = groupAndSort(packets);
|
|
64
|
+
const sections = sorted.map(renderPacketMarkdown).join("\n\n---\n\n");
|
|
65
|
+
const md = [
|
|
66
|
+
`# Polaris Review Queue`,
|
|
67
|
+
``,
|
|
68
|
+
`**Run ID:** ${runId}`,
|
|
69
|
+
`**Generated:** ${queueFile.generated_at}`,
|
|
70
|
+
`**Pending review:** ${packets.length} document(s)`,
|
|
71
|
+
``,
|
|
72
|
+
`> Markdown is display-only. Edit \`_review-queue.json\` to set \`reviewDecision\` fields.`,
|
|
73
|
+
`> Rerun \`polaris docs ingest\` to apply decisions.`,
|
|
74
|
+
``,
|
|
75
|
+
`---`,
|
|
76
|
+
``,
|
|
77
|
+
sections,
|
|
78
|
+
].join("\n");
|
|
79
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, "_review-queue.md"), md, "utf-8");
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Read review queue from JSON. Returns empty array if no queue file exists.
|
|
83
|
+
* Never reads markdown.
|
|
84
|
+
*/
|
|
85
|
+
function readReviewQueue(outputDir) {
|
|
86
|
+
const jsonPath = (0, node_path_1.join)(outputDir, "_review-queue.json");
|
|
87
|
+
if (!(0, node_fs_1.existsSync)(jsonPath))
|
|
88
|
+
return [];
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(jsonPath, "utf-8"));
|
|
91
|
+
return Array.isArray(parsed.packets) ? parsed.packets : [];
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Merge user decisions from a reviewed queue into a set of pending packets.
|
|
99
|
+
* Matches by sourcePath. Unmatched pending packets are returned unchanged.
|
|
100
|
+
*/
|
|
101
|
+
function applyReviewDecisions(pending, reviewed) {
|
|
102
|
+
const bySource = new Map(reviewed.map((r) => [r.sourcePath, r]));
|
|
103
|
+
return pending.map((p) => {
|
|
104
|
+
const decision = bySource.get(p.sourcePath);
|
|
105
|
+
if (!decision?.reviewDecision)
|
|
106
|
+
return p;
|
|
107
|
+
return {
|
|
108
|
+
...p,
|
|
109
|
+
reviewDecision: decision.reviewDecision,
|
|
110
|
+
reviewedAt: decision.reviewedAt,
|
|
111
|
+
reviewedBy: decision.reviewedBy,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.route = route;
|
|
4
|
+
function buildMinimalPacket(result, outcomeReason, recommendation) {
|
|
5
|
+
return {
|
|
6
|
+
sourcePath: "",
|
|
7
|
+
proposedDestination: "",
|
|
8
|
+
classificationConfidence: result.classificationConfidence,
|
|
9
|
+
destinationCertainty: result.destinationCertainty,
|
|
10
|
+
authorityRisk: result.authorityRisk,
|
|
11
|
+
reasoning: result.reasoning,
|
|
12
|
+
conflicts: [],
|
|
13
|
+
recommendation,
|
|
14
|
+
outcomeReason,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Pure routing function — no I/O.
|
|
19
|
+
* Implements the five-row governance decision table:
|
|
20
|
+
*
|
|
21
|
+
* Row 1: high conf + high dest + low risk → auto-route
|
|
22
|
+
* Row 2: high conf + high dest + med risk → candidate
|
|
23
|
+
* Row 3: high conf + low dest + med risk → review-required
|
|
24
|
+
* Row 4: high conf + any dest + high risk → review-required
|
|
25
|
+
* Row 5: low conf + any → review-required
|
|
26
|
+
*
|
|
27
|
+
* sourcePath and proposedDestination in the returned packet are empty strings;
|
|
28
|
+
* the caller fills them in after routing.
|
|
29
|
+
*/
|
|
30
|
+
function route(result, thresholds) {
|
|
31
|
+
const highConf = result.classificationConfidence >= thresholds.confidence;
|
|
32
|
+
const highDest = result.destinationCertainty >= thresholds.destinationCertainty;
|
|
33
|
+
// Row 1
|
|
34
|
+
if (highConf && highDest && result.authorityRisk === "low") {
|
|
35
|
+
return { outcome: "auto-route" };
|
|
36
|
+
}
|
|
37
|
+
// Row 2
|
|
38
|
+
if (highConf && highDest && result.authorityRisk === "medium") {
|
|
39
|
+
return {
|
|
40
|
+
outcome: "candidate",
|
|
41
|
+
reviewPacket: buildMinimalPacket(result, "Routed to candidate: classification and destination certainty are high, but canonical approval is still required.", "approve"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Row 3
|
|
45
|
+
if (highConf && !highDest && result.authorityRisk === "medium") {
|
|
46
|
+
return {
|
|
47
|
+
outcome: "review-required",
|
|
48
|
+
reviewPacket: buildMinimalPacket(result, "Routed to review-required: destination certainty is below threshold for medium authority risk placement.", "defer"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Row 4
|
|
52
|
+
if (highConf && result.authorityRisk === "high") {
|
|
53
|
+
return {
|
|
54
|
+
outcome: "review-required",
|
|
55
|
+
reviewPacket: buildMinimalPacket(result, "Routed to review-required: high authority risk destination requires user approval.", "defer"),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Row 5
|
|
59
|
+
return {
|
|
60
|
+
outcome: "review-required",
|
|
61
|
+
reviewPacket: buildMinimalPacket(result, `Routed to review-required: classification confidence ${result.classificationConfidence.toFixed(2)} is below threshold ${thresholds.confidence}.`, "defer"),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared types for document governance decisions: authority risk assessment, routing,
|
|
4
|
+
* and review packets. Intentionally decoupled from Smart Docs vocabulary so this module
|
|
5
|
+
* can be reused by any Polaris workflow that requires authority-boundary enforcement.
|
|
6
|
+
* The `classification` field is opaque (string) — callers define their own vocabulary.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -6,6 +6,7 @@ const node_fs_1 = require("node:fs");
|
|
|
6
6
|
const node_path_1 = require("node:path");
|
|
7
7
|
const commander_1 = require("commander");
|
|
8
8
|
const ingest_js_1 = require("./ingest.js");
|
|
9
|
+
const review_js_1 = require("./review.js");
|
|
9
10
|
const migrate_js_1 = require("./migrate.js");
|
|
10
11
|
const seed_instructions_js_1 = require("./seed-instructions.js");
|
|
11
12
|
const validate_instructions_js_1 = require("./validate-instructions.js");
|
|
@@ -45,14 +46,25 @@ function createDocsCommand(options = {}) {
|
|
|
45
46
|
docs
|
|
46
47
|
.command("ingest [path]")
|
|
47
48
|
.description("Classify and place docs into the smartdocs/ canonical authority structure")
|
|
48
|
-
.option("--file <path>", "Single file to ingest")
|
|
49
49
|
.option("--batch <cluster-id>", "Cluster ID for bounded batch ingest (reads .polaris/docs-ingest/<cluster-id>.json)")
|
|
50
50
|
.option("--cluster <id>", "Alias for --batch")
|
|
51
51
|
.option("--files <paths...>", "Bounded batch file list")
|
|
52
52
|
.option("--dry-run", "Classify and report without moving files")
|
|
53
53
|
.option("--approve-authority", "Allow placement in high-authority docs areas")
|
|
54
|
+
.option("--file <path>", "Single file to ingest; also scopes --approve-authority to a single file path")
|
|
55
|
+
.option("--from-review-queue", "scope --approve-authority to items in the review queue")
|
|
56
|
+
.option("--decision-id <id>", "scope --approve-authority to a specific decision ID")
|
|
57
|
+
.option("--interactive", "pause and prompt for review decisions on each review-required document")
|
|
58
|
+
.option("--confidence-threshold <n>", "classification confidence threshold (0–1, default 0.75)", parseFloat)
|
|
59
|
+
.option("--destination-certainty-threshold <n>", "destination certainty threshold (0–1, default 0.70)", parseFloat)
|
|
54
60
|
.option("-r, --repo-root <path>", "Repository root", defaultRepoRoot)
|
|
55
|
-
.action((pathArg,
|
|
61
|
+
.action(async (pathArg, opts) => {
|
|
62
|
+
if (opts.approveAuthority && !opts.file && !opts.fromReviewQueue && !opts.decisionId) {
|
|
63
|
+
console.error("error: --approve-authority requires an explicit scope: --file <path>, --from-review-queue, or --decision-id <id>");
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
// Alias opts to options for compatibility with existing code below
|
|
67
|
+
const options = opts;
|
|
56
68
|
const clusterId = options.batch ?? options.cluster;
|
|
57
69
|
// Validate clusterId
|
|
58
70
|
if (clusterId && !/^[A-Za-z0-9_-]+$/.test(clusterId)) {
|
|
@@ -111,6 +123,9 @@ function createDocsCommand(options = {}) {
|
|
|
111
123
|
dryRun: options.dryRun,
|
|
112
124
|
clusterId,
|
|
113
125
|
approveAuthority: options.approveAuthority,
|
|
126
|
+
interactive: opts.interactive,
|
|
127
|
+
confidenceThreshold: opts.confidenceThreshold,
|
|
128
|
+
destinationCertaintyThreshold: opts.destinationCertaintyThreshold,
|
|
114
129
|
});
|
|
115
130
|
(0, ingest_js_1.printIngestResults)(results);
|
|
116
131
|
}
|
|
@@ -119,6 +134,23 @@ function createDocsCommand(options = {}) {
|
|
|
119
134
|
process.exit(1);
|
|
120
135
|
}
|
|
121
136
|
});
|
|
137
|
+
docs
|
|
138
|
+
.command("review")
|
|
139
|
+
.description("Interactively review pending governance decisions in the review queue")
|
|
140
|
+
.option("--queue <path>", "path to _review-queue.json (default: smartdocs/raw/_review-queue.json)")
|
|
141
|
+
.option("-r, --repo-root <path>", "Repository root", defaultRepoRoot)
|
|
142
|
+
.action(async (opts) => {
|
|
143
|
+
try {
|
|
144
|
+
const queueDir = opts.queue
|
|
145
|
+
? (0, node_path_1.resolve)(opts.repoRoot, opts.queue).replace(/_review-queue\.json$/, "").replace(/\/$/, "")
|
|
146
|
+
: (0, node_path_1.resolve)(opts.repoRoot, "smartdocs", "raw");
|
|
147
|
+
await (0, review_js_1.runReviewSession)({ repoRoot: opts.repoRoot, queueDir });
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.error(`polaris docs review: ${err instanceof Error ? err.message : String(err)}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
122
154
|
docs
|
|
123
155
|
.command("migrate")
|
|
124
156
|
.description("Find scattered markdown files, move them to smartdocs/raw/, and produce an ingest cluster list")
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SMART_DOCS_SCAFFOLD_DIRS = exports.CANONICAL_TARGET = void 0;
|
|
4
4
|
exports.classifyDoc = classifyDoc;
|
|
5
|
+
exports.classifyDocWithConfidence = classifyDocWithConfidence;
|
|
5
6
|
exports.ensureDocsScaffold = ensureDocsScaffold;
|
|
6
7
|
exports.ingestDocs = ingestDocs;
|
|
7
8
|
exports.printIngestResults = printIngestResults;
|
|
@@ -13,6 +14,8 @@ const monotonic_timestamp_js_1 = require("../utils/monotonic-timestamp.js");
|
|
|
13
14
|
const smartdoc_ignore_js_1 = require("./smartdoc-ignore.js");
|
|
14
15
|
const doctrine_js_1 = require("./doctrine.js");
|
|
15
16
|
const summary_delta_js_1 = require("../cognition/summary-delta.js");
|
|
17
|
+
const authority_risk_js_1 = require("../governance/authority-risk.js");
|
|
18
|
+
const index_js_1 = require("../governance/index.js");
|
|
16
19
|
exports.CANONICAL_TARGET = "smartdocs";
|
|
17
20
|
const DOCS_INGEST_STATE_FILE = ".taskchain_artifacts/polaris-docs-ingest/current-state.json";
|
|
18
21
|
const DOCS_INGEST_RUNS_DIR = ".taskchain_artifacts/polaris-docs-ingest/runs";
|
|
@@ -23,7 +26,7 @@ const TARGET_DIRS = {
|
|
|
23
26
|
"spec-raw": `${exports.CANONICAL_TARGET}/raw`,
|
|
24
27
|
"spec-active": `${exports.CANONICAL_TARGET}/specs/active`,
|
|
25
28
|
"audit-finding": `${exports.CANONICAL_TARGET}/audits/findings`,
|
|
26
|
-
"doctrine-candidate": `${exports.CANONICAL_TARGET}/doctrine/
|
|
29
|
+
"doctrine-candidate": `${exports.CANONICAL_TARGET}/doctrine/candidate`,
|
|
27
30
|
architecture: `${exports.CANONICAL_TARGET}/architecture`,
|
|
28
31
|
decision: `${exports.CANONICAL_TARGET}/decisions`,
|
|
29
32
|
"deprecated-noise": `${exports.CANONICAL_TARGET}/runtime/generated`,
|
|
@@ -38,7 +41,6 @@ exports.SMART_DOCS_SCAFFOLD_DIRS = [
|
|
|
38
41
|
`${exports.CANONICAL_TARGET}/doctrine/active`,
|
|
39
42
|
`${exports.CANONICAL_TARGET}/doctrine/deprecated`,
|
|
40
43
|
];
|
|
41
|
-
const APPROVAL_REQUIRED = new Set(["spec-active", "architecture", "decision"]);
|
|
42
44
|
/**
|
|
43
45
|
* Conflict detection uses subject-verb-keyword triples rather than bare
|
|
44
46
|
* verb-keyword pairs. A conflict only fires when both the subject AND the
|
|
@@ -107,6 +109,87 @@ function classifyDoc(content, filePath = "") {
|
|
|
107
109
|
}
|
|
108
110
|
return "spec-raw";
|
|
109
111
|
}
|
|
112
|
+
const DOMAIN_KEYWORDS_RE = /\b(loop|map|finalize|config|cli|docs|doctrine|spec|audit)\b/i;
|
|
113
|
+
function classifyDocWithConfidence(content, filePath) {
|
|
114
|
+
const classification = classifyDoc(content, filePath ?? "");
|
|
115
|
+
const reasoning = [];
|
|
116
|
+
// --- classificationConfidence ---
|
|
117
|
+
let classConf = 0.3;
|
|
118
|
+
const docType = frontMatterValue(content, "doc-type");
|
|
119
|
+
if (docType) {
|
|
120
|
+
classConf += 0.4;
|
|
121
|
+
reasoning.push(`Explicit frontmatter doc-type: ${docType}`);
|
|
122
|
+
}
|
|
123
|
+
const authority = frontMatterValue(content, "authority")?.toLowerCase();
|
|
124
|
+
const status = frontMatterValue(content, "status")?.toLowerCase();
|
|
125
|
+
const hasSupportingFm = authority || status;
|
|
126
|
+
if (hasSupportingFm) {
|
|
127
|
+
// check alignment: authority or status loosely matches classification
|
|
128
|
+
const classLower = classification.toLowerCase();
|
|
129
|
+
if ((authority && classLower.includes(authority)) ||
|
|
130
|
+
(status && classLower.includes(status))) {
|
|
131
|
+
classConf += 0.2;
|
|
132
|
+
reasoning.push(`Frontmatter authority/status aligns with classification`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Count independent keyword signals from classifyDoc logic
|
|
136
|
+
const lower = `${filePath ?? ""}\n${content}`.toLowerCase();
|
|
137
|
+
let keywordSignals = 0;
|
|
138
|
+
if (lower.includes("run report") || lower.includes("run-report"))
|
|
139
|
+
keywordSignals++;
|
|
140
|
+
if (lower.includes("runtime summary") || lower.includes("session summary"))
|
|
141
|
+
keywordSignals++;
|
|
142
|
+
if (lower.includes("audit finding") || lower.includes("vulnerability") || lower.includes("security audit"))
|
|
143
|
+
keywordSignals++;
|
|
144
|
+
if (lower.includes("doctrine") || lower.includes("must always") || lower.includes("never silently"))
|
|
145
|
+
keywordSignals++;
|
|
146
|
+
if (lower.includes("architecture decision record") || /^#\s*adr[:\s-]/im.test(content))
|
|
147
|
+
keywordSignals++;
|
|
148
|
+
if (lower.includes("architecture") || lower.includes("structural design"))
|
|
149
|
+
keywordSignals++;
|
|
150
|
+
if (lower.includes("active spec"))
|
|
151
|
+
keywordSignals++;
|
|
152
|
+
if (lower.includes("acceptance criteria") || lower.includes("implementation plan"))
|
|
153
|
+
keywordSignals++;
|
|
154
|
+
if (keywordSignals >= 2) {
|
|
155
|
+
classConf += 0.3;
|
|
156
|
+
reasoning.push(`Multiple (${keywordSignals}) independent keyword signals matched`);
|
|
157
|
+
}
|
|
158
|
+
else if (keywordSignals === 1) {
|
|
159
|
+
classConf += 0.1;
|
|
160
|
+
reasoning.push(`Single keyword signal matched`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
reasoning.push(`Default spec-raw fallback with no signals`);
|
|
164
|
+
}
|
|
165
|
+
classConf = Math.min(classConf, 1.0);
|
|
166
|
+
// --- destinationCertainty ---
|
|
167
|
+
let destCert = 0.2;
|
|
168
|
+
const linkedMapArea = frontMatterValue(content, "linked-map-area");
|
|
169
|
+
if (linkedMapArea) {
|
|
170
|
+
destCert += 0.4;
|
|
171
|
+
reasoning.push(`Frontmatter linked-map-area: ${linkedMapArea}`);
|
|
172
|
+
}
|
|
173
|
+
if (filePath && DOMAIN_KEYWORDS_RE.test(filePath)) {
|
|
174
|
+
destCert += 0.2;
|
|
175
|
+
reasoning.push(`Domain keyword found in filename`);
|
|
176
|
+
}
|
|
177
|
+
const isDefaultFallback = classification === "spec-raw" && keywordSignals === 0 && !docType;
|
|
178
|
+
if (!isDefaultFallback) {
|
|
179
|
+
destCert += 0.2;
|
|
180
|
+
reasoning.push(`Classification is not default fallback`);
|
|
181
|
+
}
|
|
182
|
+
destCert = Math.min(destCert, 1.0);
|
|
183
|
+
const proposedDest = TARGET_DIRS[classification] ?? "";
|
|
184
|
+
const authorityRisk = (0, authority_risk_js_1.computeAuthorityRisk)(classification, proposedDest);
|
|
185
|
+
return {
|
|
186
|
+
classification,
|
|
187
|
+
classificationConfidence: classConf,
|
|
188
|
+
destinationCertainty: destCert,
|
|
189
|
+
authorityRisk,
|
|
190
|
+
reasoning,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
110
193
|
function readCurrentState(repoRoot) {
|
|
111
194
|
return readJson((0, node_path_1.resolve)(repoRoot, DOCS_INGEST_STATE_FILE), {});
|
|
112
195
|
}
|
|
@@ -315,6 +398,9 @@ function ingestDocs(files, options) {
|
|
|
315
398
|
const priorState = readCurrentState(repoRoot);
|
|
316
399
|
const clusterId = options.clusterId ?? null;
|
|
317
400
|
const runId = generateRunId(repoRoot);
|
|
401
|
+
const rawDir = (0, node_path_1.resolve)(repoRoot, exports.CANONICAL_TARGET, "raw");
|
|
402
|
+
// Read existing review queue to apply prior human decisions
|
|
403
|
+
const priorQueue = (0, index_js_1.readReviewQueue)(rawDir);
|
|
318
404
|
// Emit run-start telemetry (STOP CONDITION if this write fails)
|
|
319
405
|
emitRunStartTelemetry(repoRoot, runId, priorState.run_id ?? null);
|
|
320
406
|
const telPath = docsIngestTelemetryPath(repoRoot, runId);
|
|
@@ -349,10 +435,6 @@ function ingestDocs(files, options) {
|
|
|
349
435
|
if (!(0, node_fs_1.existsSync)(absSource))
|
|
350
436
|
throw new Error(`polaris docs ingest: file not found: ${source}`);
|
|
351
437
|
const content = (0, node_fs_1.readFileSync)(absSource, "utf-8");
|
|
352
|
-
const classification = classifyDoc(content, relSource);
|
|
353
|
-
if (APPROVAL_REQUIRED.has(classification) && !options.approveAuthority) {
|
|
354
|
-
throw new Error(`polaris docs ingest: ${classification} requires explicit approval; rerun with --approve-authority`);
|
|
355
|
-
}
|
|
356
438
|
// Conflict detection against active doctrine (STOP CONDITION)
|
|
357
439
|
const conflict = detectDoctrineConflict(content, repoRoot);
|
|
358
440
|
if (conflict) {
|
|
@@ -365,8 +447,46 @@ function ingestDocs(files, options) {
|
|
|
365
447
|
});
|
|
366
448
|
throw new Error(`polaris docs ingest: conflict detected — ${conflict.detail}`);
|
|
367
449
|
}
|
|
450
|
+
// ── Governance routing ────────────────────────────────────────────────
|
|
451
|
+
const classificationResult = classifyDocWithConfidence(content, relSource);
|
|
452
|
+
const docClassification = classificationResult.classification;
|
|
453
|
+
const thresholds = {
|
|
454
|
+
confidence: options.confidenceThreshold ?? 0.75,
|
|
455
|
+
destinationCertainty: options.destinationCertaintyThreshold ?? 0.70,
|
|
456
|
+
};
|
|
457
|
+
const routingDecision = (0, index_js_1.route)(classificationResult, thresholds);
|
|
458
|
+
const proposedDest = (0, node_path_1.relative)(repoRoot, (0, node_path_1.join)((0, node_path_1.resolve)(repoRoot, TARGET_DIRS[docClassification]), (0, node_path_1.basename)(absSource))).replace(/\\/g, "/");
|
|
459
|
+
// review-required: leave in raw/, emit packet, skip move
|
|
460
|
+
if (routingDecision.outcome === "review-required") {
|
|
461
|
+
const packet = {
|
|
462
|
+
...routingDecision.reviewPacket,
|
|
463
|
+
sourcePath: relSource,
|
|
464
|
+
proposedDestination: proposedDest,
|
|
465
|
+
conflicts: [],
|
|
466
|
+
};
|
|
467
|
+
emitTelemetry(telPath, runId, {
|
|
468
|
+
event: "docs-ingest-review-required",
|
|
469
|
+
file: relSource,
|
|
470
|
+
classification: docClassification,
|
|
471
|
+
outcome_reason: packet.outcomeReason,
|
|
472
|
+
cluster_id: clusterId,
|
|
473
|
+
});
|
|
474
|
+
results.push({
|
|
475
|
+
sourcePath: relSource,
|
|
476
|
+
destinationPath: relSource,
|
|
477
|
+
classification: docClassification,
|
|
478
|
+
linkedMapArea: null,
|
|
479
|
+
runId,
|
|
480
|
+
dryRun: Boolean(options.dryRun),
|
|
481
|
+
nearestSummary: null,
|
|
482
|
+
summaryDeltaWarranted: false,
|
|
483
|
+
routingDecision: "review-required",
|
|
484
|
+
reviewPacket: packet,
|
|
485
|
+
});
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
368
488
|
const { label: linkedMapArea, entry: linkedEntry } = deriveLinkedArea(content, routes);
|
|
369
|
-
const targetDir = (0, node_path_1.resolve)(repoRoot, TARGET_DIRS[
|
|
489
|
+
const targetDir = (0, node_path_1.resolve)(repoRoot, TARGET_DIRS[docClassification]);
|
|
370
490
|
(0, node_fs_1.mkdirSync)(targetDir, { recursive: true });
|
|
371
491
|
const rawDestination = (0, node_path_1.join)(targetDir, (0, node_path_1.basename)(absSource));
|
|
372
492
|
const destination = (0, node_path_1.resolve)(rawDestination) === (0, node_path_1.resolve)(absSource)
|
|
@@ -379,7 +499,7 @@ function ingestDocs(files, options) {
|
|
|
379
499
|
emitTelemetry(telPath, runId, {
|
|
380
500
|
event: "docs-ingest-classified",
|
|
381
501
|
file: relSource,
|
|
382
|
-
classification,
|
|
502
|
+
classification: docClassification,
|
|
383
503
|
destination: relDestination,
|
|
384
504
|
linked_map_area: linkedMapArea,
|
|
385
505
|
cluster_id: clusterId,
|
|
@@ -390,7 +510,7 @@ function ingestDocs(files, options) {
|
|
|
390
510
|
}
|
|
391
511
|
const stampedContent = (0, doctrine_js_1.stampIngestFrontMatter)((0, node_fs_1.readFileSync)(destination, "utf-8"), {
|
|
392
512
|
originalPath: relSource,
|
|
393
|
-
classifiedAs:
|
|
513
|
+
classifiedAs: docClassification,
|
|
394
514
|
ingestRunId: runId,
|
|
395
515
|
ingestClusterId: clusterId,
|
|
396
516
|
linkedMapArea,
|
|
@@ -399,11 +519,11 @@ function ingestDocs(files, options) {
|
|
|
399
519
|
(0, node_fs_1.writeFileSync)(destination, stampedContent, "utf-8");
|
|
400
520
|
updateMapEntry(repoRoot, destination, linkedEntry);
|
|
401
521
|
}
|
|
402
|
-
if (
|
|
522
|
+
if (docClassification === "doctrine-candidate" && !options.dryRun) {
|
|
403
523
|
emitTelemetry(telPath, runId, {
|
|
404
524
|
event: "doc-auto-promoted",
|
|
405
525
|
file: relDestination,
|
|
406
|
-
classification,
|
|
526
|
+
classification: docClassification,
|
|
407
527
|
linked_map_area: linkedMapArea,
|
|
408
528
|
cluster_id: clusterId,
|
|
409
529
|
});
|
|
@@ -411,7 +531,7 @@ function ingestDocs(files, options) {
|
|
|
411
531
|
emitTelemetry(telPath, runId, {
|
|
412
532
|
event: "docs-ingest",
|
|
413
533
|
file: relSource,
|
|
414
|
-
classification,
|
|
534
|
+
classification: docClassification,
|
|
415
535
|
destination: relDestination,
|
|
416
536
|
linked_map_area: linkedMapArea,
|
|
417
537
|
cluster_id: clusterId,
|
|
@@ -457,12 +577,16 @@ function ingestDocs(files, options) {
|
|
|
457
577
|
results.push({
|
|
458
578
|
sourcePath: relSource,
|
|
459
579
|
destinationPath: relDestination,
|
|
460
|
-
classification,
|
|
580
|
+
classification: docClassification,
|
|
461
581
|
linkedMapArea,
|
|
462
582
|
runId,
|
|
463
583
|
dryRun: Boolean(options.dryRun),
|
|
464
584
|
nearestSummary,
|
|
465
585
|
summaryDeltaWarranted: summaryDelta.updateWarranted,
|
|
586
|
+
routingDecision: routingDecision.outcome,
|
|
587
|
+
reviewPacket: routingDecision.reviewPacket
|
|
588
|
+
? { ...routingDecision.reviewPacket, sourcePath: relSource, proposedDestination: relDestination, conflicts: [] }
|
|
589
|
+
: undefined,
|
|
466
590
|
});
|
|
467
591
|
}
|
|
468
592
|
emitTelemetry(telPath, runId, {
|
|
@@ -470,6 +594,15 @@ function ingestDocs(files, options) {
|
|
|
470
594
|
count: results.length,
|
|
471
595
|
cluster_id: clusterId,
|
|
472
596
|
});
|
|
597
|
+
// Write review queue if any review-required results exist
|
|
598
|
+
const reviewPackets = results
|
|
599
|
+
.filter(r => r.routingDecision === "review-required" && r.reviewPacket)
|
|
600
|
+
.map(r => r.reviewPacket);
|
|
601
|
+
if (reviewPackets.length > 0) {
|
|
602
|
+
// Apply any prior human decisions before writing
|
|
603
|
+
const mergedPackets = (0, index_js_1.applyReviewDecisions)(reviewPackets, priorQueue);
|
|
604
|
+
(0, index_js_1.writeReviewQueue)(mergedPackets, runId, rawDir);
|
|
605
|
+
}
|
|
473
606
|
if (!options.dryRun) {
|
|
474
607
|
writeDocsIngestState(repoRoot, {
|
|
475
608
|
run_id: runId,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.filterUndecided = filterUndecided;
|
|
37
|
+
exports.formatPacketCard = formatPacketCard;
|
|
38
|
+
exports.runReviewSession = runReviewSession;
|
|
39
|
+
const node_child_process_1 = require("node:child_process");
|
|
40
|
+
const readline = __importStar(require("node:readline"));
|
|
41
|
+
const node_path_1 = require("node:path");
|
|
42
|
+
const index_js_1 = require("../governance/index.js");
|
|
43
|
+
const ingest_js_1 = require("./ingest.js");
|
|
44
|
+
function filterUndecided(packets) {
|
|
45
|
+
return packets.filter((p) => p.reviewDecision === undefined || p.reviewDecision === "defer");
|
|
46
|
+
}
|
|
47
|
+
function formatPacketCard(packet, index, total) {
|
|
48
|
+
const divider = "─".repeat(65);
|
|
49
|
+
return [
|
|
50
|
+
divider,
|
|
51
|
+
`[${index}/${total}] ${packet.sourcePath}`,
|
|
52
|
+
` → ${packet.proposedDestination}`,
|
|
53
|
+
` Authority risk: ${packet.authorityRisk.toUpperCase()}`,
|
|
54
|
+
` Recommendation: ${packet.recommendation}`,
|
|
55
|
+
``,
|
|
56
|
+
`[a]pprove [r]eject [d]efer [s]kip [q]uit`,
|
|
57
|
+
divider,
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
function defaultGetReviewedBy() {
|
|
61
|
+
try {
|
|
62
|
+
return (0, node_child_process_1.execSync)("git config user.name", { encoding: "utf-8" }).trim() || "unknown";
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return "unknown";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function readSingleKey() {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
readline.emitKeypressEvents(process.stdin);
|
|
71
|
+
if (process.stdin.isTTY)
|
|
72
|
+
process.stdin.setRawMode(true);
|
|
73
|
+
const handler = (_str, key) => {
|
|
74
|
+
if (process.stdin.isTTY)
|
|
75
|
+
process.stdin.setRawMode(false);
|
|
76
|
+
process.stdin.removeListener("keypress", handler);
|
|
77
|
+
if (key?.ctrl && key.name === "c")
|
|
78
|
+
process.exit(0);
|
|
79
|
+
resolve(key?.name ?? _str ?? "");
|
|
80
|
+
};
|
|
81
|
+
process.stdin.on("keypress", handler);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function runReviewSession(options) {
|
|
85
|
+
const { repoRoot, readKey, getReviewedBy = defaultGetReviewedBy, output = (msg) => process.stdout.write(msg + "\n"), } = options;
|
|
86
|
+
const queueDir = options.queueDir ?? (0, node_path_1.resolve)(repoRoot, "smartdocs", "raw");
|
|
87
|
+
const packets = (0, index_js_1.readReviewQueue)(queueDir);
|
|
88
|
+
if (packets.length === 0) {
|
|
89
|
+
output("No review queue found. Run polaris docs ingest first.");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const undecided = filterUndecided(packets);
|
|
93
|
+
if (undecided.length === 0) {
|
|
94
|
+
output("Nothing to review. All decisions are final — run polaris docs ingest to apply.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let decided = 0;
|
|
98
|
+
for (let i = 0; i < undecided.length; i++) {
|
|
99
|
+
const packet = undecided[i];
|
|
100
|
+
output(formatPacketCard(packet, i + 1, undecided.length));
|
|
101
|
+
const key = readKey ? await readKey() : await readSingleKey();
|
|
102
|
+
if (key === "q") {
|
|
103
|
+
const remaining = undecided.length - decided;
|
|
104
|
+
output(`\nSession ended. ${decided} decision(s) saved, ${remaining} packet(s) still pending.`);
|
|
105
|
+
output("Run polaris docs review to continue.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (key === "s") {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const decision = key === "a" ? "approve" :
|
|
112
|
+
key === "r" ? "reject" :
|
|
113
|
+
key === "d" ? "defer" :
|
|
114
|
+
null;
|
|
115
|
+
if (!decision) {
|
|
116
|
+
output("Unrecognized key. Use [a], [r], [d], [s], or [q].");
|
|
117
|
+
i--;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const idx = packets.findIndex((p) => p.sourcePath === packet.sourcePath);
|
|
121
|
+
if (idx !== -1) {
|
|
122
|
+
packets[idx] = {
|
|
123
|
+
...packets[idx],
|
|
124
|
+
reviewDecision: decision,
|
|
125
|
+
reviewedAt: new Date().toISOString(),
|
|
126
|
+
reviewedBy: getReviewedBy(),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
(0, index_js_1.writeReviewQueue)(packets, "review-session", queueDir);
|
|
130
|
+
decided++;
|
|
131
|
+
}
|
|
132
|
+
const approved = packets.filter((p) => p.reviewDecision === "approve").length;
|
|
133
|
+
const rejected = packets.filter((p) => p.reviewDecision === "reject").length;
|
|
134
|
+
const deferred = packets.filter((p) => p.reviewDecision === "defer").length;
|
|
135
|
+
output(`\nReview complete: ${approved} approved, ${rejected} rejected, ${deferred} deferred.`);
|
|
136
|
+
const pendingFiles = packets
|
|
137
|
+
.filter((p) => p.reviewDecision === "approve" || p.reviewDecision === "reject")
|
|
138
|
+
.map((p) => p.sourcePath);
|
|
139
|
+
if (pendingFiles.length > 0) {
|
|
140
|
+
output("Running docs ingest to apply decisions...");
|
|
141
|
+
try {
|
|
142
|
+
const results = (0, ingest_js_1.ingestDocs)(pendingFiles, { repoRoot });
|
|
143
|
+
(0, ingest_js_1.printIngestResults)(results);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
output(`Ingest error: ${err instanceof Error ? err.message : String(err)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|